Files
blackbird/aibox/model_config.py
T
qing 3e6e171831 fix(aibox): Batch M 波次5 客户端收尾(M1~M5/M7/M8)
- voice_client: stdin 单守护线程+Queue 替代每行 to_thread(泄漏/丢行);
  arecord 探测 timeout=5 + finally 清理临时文件
- client: main() finally 统一 disconnect websocket
- model_config: 主循环捕获 EOF/KeyboardInterrupt;api 节非 dict 重置;
  fallback 字符串规整(展示/删除清理);api_key_env 正则校验+
  显示字段拒控制字符限长
- 冒烟 7/7 通过;全量 493 passed/4 failed(既有平台项)/1 skipped 零回归
- 审计报告补 §22,Batch M 五波次收束
2026-08-20 14:40:00 +08:00

455 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型接入点(模型档案)交互式管理工具
不用手写 config.json:菜单式列出 / 添加 / 删除 / 连通性验证
`models` 节的模型接入点(OpenAI Chat Completions 兼容端点:
DeepSeek / Kimi / Qwen / GLM / 本地 Ollama 等)。
用法:
python model_config.py # 交互式菜单
python model_config.py --list # 非交互:列出当前接入点
安全约定(与 aibox 审计一致):API Key 一律不写入配置文件——
只保存环境变量名(api_key_env),或留空表示免密本地端点。
"""
import argparse
import os
import re
import sys
from typing import Dict, List, Optional
# 允许从仓库根目录或 aibox/ 目录启动
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import ConfigManager # noqa: E402
from llm_client import _sanitize_log_msg # noqa: E402
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# 常见厂商端点提示(首行是默认建议值)
PROVIDER_HINTS = {
"DeepSeek": "https://api.deepseek.com/v1/chat/completions",
"Kimi (Moonshot)": "https://api.moonshot.cn/v1/chat/completions",
"Qwen (阿里云百炼)": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"GLM (智谱)": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
"Ollama 本地": "http://localhost:11434/v1/chat/completions",
}
# ==================== 核心逻辑(可单测,不依赖 input) ====================
def get_models(config: ConfigManager) -> Dict:
"""取 models 节(不存在则初始化为空 dict)。"""
models = config.get("models") or {}
if not isinstance(models, dict):
models = {}
return models
def _api_section(config: ConfigManager) -> Dict:
"""M3(审计 2026-08-20):api 节被改坏为非 dict 时重置为空节——
否则 setdefault 后索引赋值抛 TypeError/AttributeError"""
api = config._config.get("api")
if not isinstance(api, dict):
api = config._config["api"] = {}
return api
def list_profiles(config: ConfigManager) -> List[Dict]:
"""列出所有档案的归一化展示数据。"""
models = get_models(config)
default = config.get("api.default_model") or ""
profiles = []
for name, profile in models.items():
if not isinstance(profile, dict):
continue
# M4(审计 2026-08-20):fallback 被改坏为字符串时原样下传,
# 展示层 join 成单字符误导——统一规整为列表
fallback = profile.get("fallback")
profiles.append({
"name": name,
"is_default": name == default,
"display_name": profile.get("display_name") or name,
"api_url": profile.get("api_url", ""),
"model": profile.get("model", ""),
"api_key_env": profile.get("api_key_env", ""),
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
"temperature": profile.get("temperature"),
"timeout": profile.get("timeout"),
"fallback": fallback if isinstance(fallback, list) else [],
})
return profiles
def validate_new_profile(config: ConfigManager, name: str, api_url: str,
model: str) -> Optional[str]:
"""校验新档案字段,返回错误信息(合法返回 None)。"""
if not name:
return "档案名不能为空"
if not PROFILE_NAME_RE.match(name):
return f"档案名只能包含字母/数字/下划线/短横线(当前: {name})"
models = get_models(config)
if name in models:
return f"档案名 '{name}' 已存在"
if not api_url or not str(api_url).strip().lower().startswith(("http://", "https://")):
return "API 地址必须是 http:// 或 https:// 开头的完整 URL"
if not model or not str(model).strip():
return "模型名不能为空(如 deepseek-chat / gpt-4o / qwen-plus)"
return None
def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
api_key_env: str = "", display_name: str = None,
temperature: float = None, timeout: int = None,
reasoning_enabled: bool = False,
fallback: Optional[List[str]] = None,
set_default: bool = False) -> List[str]:
"""添加档案。返回提示信息列表;字段非法时返回带 ❌ 的错误信息。"""
err = validate_new_profile(config, name, api_url, model)
if err:
return [f"❌ {err}"]
# M5(审计 2026-08-20):api_key_env 需为合法环境变量名;展示/地址字段
# 拒控制字符并限长——防终端注入回显(\x1b] 等序列写入配置后被 print)
api_key_env = (api_key_env or "").strip()
if api_key_env and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", api_key_env):
return [f"❌ 环境变量名不合法: {api_key_env!r}(须为字母/数字/下划线且不以数字开头)"]
for label, val, limit in (("显示名", display_name, 64),
("模型名", model, 128),
("API 地址", api_url, 256)):
if val and (len(str(val)) > limit or not all(c.isprintable() for c in str(val))):
return [f"❌ {label}含控制字符或超过 {limit} 字符"]
models = get_models(config)
profile = {
"api_url": str(api_url).strip(),
"model": str(model).strip(),
}
if api_key_env:
profile["api_key_env"] = api_key_env.strip()
else:
# 空串 = 免密本地端点(Ollama/vLLM),显式声明
profile["api_key_env"] = ""
if display_name:
profile["display_name"] = display_name.strip()
if temperature is not None:
try:
profile["temperature"] = max(0.0, min(2.0, float(temperature)))
except (TypeError, ValueError):
return ["❌ 温度必须是 0.0~2.0 的数字"]
if timeout is not None:
try:
profile["timeout"] = max(5, int(timeout))
except (TypeError, ValueError):
return ["❌ 超时必须是整数秒"]
if reasoning_enabled:
profile["reasoning_enabled"] = True
if fallback:
# 只保留已存在的档案,避免悬空引用(L4a 启动期硬性报错)
known = set(models.keys())
invalid = [f for f in fallback if f not in known or f == name]
if invalid:
return [f"❌ 失败转移包含不存在的档案或自身: {invalid},请重新选择"]
profile["fallback"] = list(fallback)
models[name] = profile
config._config["models"] = models
if set_default:
_api_section(config)["default_model"] = name
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
if not saved:
msgs.append("⚠️ 保存失败(仅本次运行生效,重启后丢失)——请检查配置文件目录权限")
if set_default:
msgs.append(f"⭐ 已设为默认接入点(--model 不指定时使用)")
if not api_key_env:
msgs.append("ℹ️ 免密端点:请求不携带 API Key(适用于 Ollama/vLLM 等本地服务)")
else:
msgs.append(f"🔑 Key 从环境变量 {api_key_env} 读取(未设置时该档案不可用)")
return msgs
def delete_profile(config: ConfigManager, name: str) -> List[str]:
"""删除档案,并自动清理其他档案 fallback 中的悬空引用。"""
models = get_models(config)
if name not in models:
return [f"❌ 档案 '{name}' 不存在"]
del models[name]
# 清理其他档案对该档案的 fallback 引用(悬空引用会导致启动期报错)
cleaned = []
for other, profile in models.items():
if not isinstance(profile, dict):
continue
fb = profile.get("fallback")
# M4(审计 2026-08-20):fallback 为字符串时 `name in fb` 退化为子串
# 误判,且下方列表推导会把字符串拆成单字符数组写回配置
if not isinstance(fb, list):
continue
if name in fb:
profile["fallback"] = [f for f in fb if f != name]
cleaned.append(other)
config._config["models"] = models
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
if config.get("api.default_model") == name:
_api_section(config).pop("default_model", None)
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已删除"]
if cleaned:
msgs.append(f"ℹ️ 已同时清理 {len(cleaned)} 个档案的失败转移引用: {', '.join(cleaned)}")
if not saved:
msgs.append("⚠️ 保存失败——请检查配置文件目录权限")
return msgs
def test_profile(config: ConfigManager, name: str, logger=None) -> str:
"""连通性验证:向该档案端点发一次最小请求。"""
models = get_models(config)
if name not in models:
return f"❌ 档案 '{name}' 不存在"
try:
from llm_client import create_llm_client
client = create_llm_client(config, api_key=None, logger=logger, model=name)
result = client.chat_completion(
[{"role": "user", "content": "ping"}], max_tokens=5)
if result is None or not str(result).strip():
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
except Exception as e:
# J4(审计 2026-08-19):异常消息可能含 response.text 片段,网关/WAF
# 错误页会回显 Authorization 头——脱敏 Bearer 令牌后再回显终端
return f"❌ 档案 '{name}' 验证失败:{_sanitize_log_msg(str(e))}"
# ==================== 交互式菜单 ====================
def _ask(prompt: str, default: str = "") -> str:
if default:
val = input(f"{prompt}(回车默认: {default}): ").strip()
return val if val else default
return input(f"{prompt}: ").strip()
def _ask_yes_no(prompt: str, default: bool = False) -> bool:
hint = "y/n" if not default else "Y/n"
while True:
val = input(f"{prompt} [{hint}] ").strip().lower()
if not val:
return default
if val in ("y", "yes", "是"):
return True
if val in ("n", "no", "否"):
return False
print(" 请输入 y 或 n")
def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]:
"""编号选择(单选或多选)。多选用逗号分隔,直接回车 = 不选。"""
for i, opt in enumerate(options, 1):
print(f" {i}. {opt}")
if multi:
while True:
raw = input(f"{prompt}(多选,逗号分隔,直接回车不选): ").strip()
if not raw:
return []
try:
idxs = [int(x) for x in raw.replace(",", ",").split(",") if x.strip()]
except ValueError:
print(" 请输入数字编号,逗号分隔")
continue
if all(1 <= i <= len(options) for i in idxs):
return [options[i - 1] for i in idxs]
print(f" 编号超出范围(1~{len(options)})")
else:
while True:
raw = input(f"{prompt}(1~{len(options)},直接回车不选): ").strip()
if not raw:
return []
try:
idx = int(raw)
except ValueError:
print(" 请输入数字编号")
continue
if 1 <= idx <= len(options):
return [options[idx - 1]]
print(f" 编号超出范围(1~{len(options)})")
def _print_profiles(profiles: List[Dict]):
if not profiles:
print(" (当前没有任何接入点。旧版 api 节仍可用,或选 2 添加)")
return
for p in profiles:
mark = " ⭐默认" if p["is_default"] else ""
key_desc = f"环境变量 {p['api_key_env']}" if p["api_key_env"] else "免密"
fb = f",失败转移 → {', '.join(p['fallback'])}" if p["fallback"] else ""
print(f" {p['name']}{mark} [{p['display_name']}]")
print(f" 地址: {p['api_url']} 模型: {p['model']}")
print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}")
def _add_flow(config: ConfigManager):
print("\n── 添加接入点 ──")
print("常见厂商端点参考:")
for k, v in PROVIDER_HINTS.items():
print(f" {k}: {v}")
name = _ask("档案名(英文,如 deepseek/kimi/local)")
if not name:
print("❌ 已取消(档案名不能为空)")
return
if not PROFILE_NAME_RE.match(name):
print(f"❌ 档案名只能包含字母/数字/下划线/短横线")
return
if name in get_models(config):
print(f"❌ 档案名 '{name}' 已存在")
return
api_url = _ask("API 地址")
model = _ask("模型名(如 deepseek-chat / qwen-plus)")
print("Key 方式:")
print(" 1. 环境变量名(推荐,如 DEEPSEEK_API_KEY——Key 不落盘,重启不丢)")
print(" 2. 免密本地端点(如 Ollama,不需要 Key)")
key_mode = _ask("选择 [1/2]", "1")
api_key_env = ""
if key_mode == "1":
api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY")
elif key_mode != "2":
print("❌ 已取消(Key 方式无效)")
return
display_name = _ask("显示名(可选,回车用档案名)")
temperature_raw = _ask("温度 0.0~2.0(可选,越低越确定)")
timeout_raw = _ask("超时秒数(可选)")
reasoning = _ask_yes_no("启用思考模式?", default=False)
profiles = list_profiles(config)
fallback = []
if profiles:
print("失败转移:主接入点故障时按顺序尝试的备用接入点(可选)")
fallback = _choose("选择备用接入点", [p["name"] for p in profiles], multi=True)
set_default = _ask_yes_no("设为默认接入点?", default=(len(profiles) == 0))
print("\n── 确认 ──")
print(f" 档案名: {name}")
print(f" 地址: {api_url} 模型: {model}")
print(f" Key: {'环境变量 ' + api_key_env if api_key_env else '免密(不携带)'}")
if display_name:
print(f" 显示名: {display_name}")
if temperature_raw:
print(f" 温度: {temperature_raw}")
if timeout_raw:
print(f" 超时: {timeout_raw}s")
if reasoning:
print(" 思考模式: 开启")
if fallback:
print(f" 失败转移: {', '.join(fallback)}")
if not _ask_yes_no("确认保存?", default=True):
print("已取消")
return
# J8(审计 2026-08-19):原始字符串直传——此前交互层先 float()/int(),
# 非数字输入直接 traceback 退出,add_profile 内部的容错成为死代码
msgs = add_profile(config, name, api_url, model,
api_key_env=api_key_env, display_name=display_name,
temperature=temperature_raw or None,
timeout=timeout_raw or None,
reasoning_enabled=reasoning, fallback=fallback,
set_default=set_default)
for m in msgs:
print(m)
def _delete_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 删除接入点 ──")
picked = _choose("选择要删除的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
name = picked[0]
if not _ask_yes_no(f"确认删除 '{name}'?(其他档案的失败转移引用会自动清理)", default=False):
print("已取消")
return
for m in delete_profile(config, name):
print(m)
def _test_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 连通性验证 ──")
picked = _choose("选择要验证的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
print(" 正在请求端点(最长 20 秒)...")
print(test_profile(config, picked[0]))
def run_interactive(config: ConfigManager = None):
"""交互式主循环。config 可注入(测试用)。"""
config = config or ConfigManager()
config_path = getattr(config, "_config_path", None) or "(默认位置)"
print("=" * 56)
print(" aibox 大模型接入点管理工具")
print(f" 配置文件: {config_path}")
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
print("=" * 56)
while True:
# M2(审计 2026-08-20):input 未捕获 EOFError/KeyboardInterrupt——
# 管道耗尽/Ctrl+C 此前直接 traceback;整循环包 try 同时覆盖子流程
# (_add_flow/_delete_flow/_test_flow)内的 input 调用
try:
print("\n请选择操作:")
print(" 1. 📋 列出接入点")
print(" 2. ➕ 添加接入点")
print(" 3. 🗑️ 删除接入点")
print(" 4. 🔍 验证接入点连通性")
print(" 0. 退出")
choice = input("> ").strip()
if choice == "1":
_print_profiles(list_profiles(config))
elif choice == "2":
_add_flow(config)
elif choice == "3":
_delete_flow(config)
elif choice == "4":
_test_flow(config)
elif choice in ("0", "q", "exit", "退出"):
print("再见")
return
else:
print("无效选择,请输入 0~4")
except (EOFError, KeyboardInterrupt):
print("\n再见")
return
def main():
parser = argparse.ArgumentParser(description="aibox 大模型接入点管理工具")
parser.add_argument("--list", action="store_true", help="非交互:列出当前接入点")
args = parser.parse_args()
config = ConfigManager()
if args.list:
_print_profiles(list_profiles(config))
return
run_interactive(config)
if __name__ == "__main__":
main()