diff --git a/aibox/README.md b/aibox/README.md index 7f5b87a..b76cc87 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -546,8 +546,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i | 字段 | 说明 | |---|---| -| `api_url` | 端点 URL(OpenAI Chat Completions 协议) | -| `model` | 该档案使用的模型名 | +| `api_url` | **必填**。端点 URL(OpenAI Chat Completions 协议) | +| `model` | **必填**(除非调用时传裸模型名)。该档案使用的模型名 | | `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) | | `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) | | `timeout` | 请求超时秒数 | diff --git a/aibox/llm_client.py b/aibox/llm_client.py index 7fe338d..24098bf 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -262,6 +262,26 @@ def resolve_api_key(api_key: Optional[str], config=None, f"请设置环境变量 {env_name} 或在配置文件中设置 API Key") +def _validate_profile(name: str, profile) -> None: + """H2:档案类型与 api_url 校验(在读取档案字段之前调用)。""" + if not isinstance(profile, dict): + raise ValueError( + f"模型档案 '{name}' 配置无效:应为对象(字典)," + f"实际为 {type(profile).__name__},请检查 config.json 的 models.{name}") + if not str(profile.get("api_url") or "").strip(): + raise ValueError( + f"模型档案 '{name}' 缺少 api_url 字段," + f"请在 config.json 的 models.{name} 中补齐(OpenAI Chat Completions 端点 URL)") + + +def _require_model(name: str, effective_model) -> None: + """H2:有效模型名校验——缺 model 会把档案名当模型名发给 API(或 "model": null → 400)。""" + if not str(effective_model or "").strip(): + raise ValueError( + f"模型档案 '{name}' 缺少 model 字段(且未传入裸模型名)," + f"请在 config.json 的 models.{name} 中补齐,或调用时用 --model 指定模型名") + + def _resolve_model_profile(config, model: Optional[str]): """解析模型档案。返回 (profile_dict, effective_model_name)。 @@ -279,11 +299,18 @@ def _resolve_model_profile(config, model: Optional[str]): default_profile = models.get(default_name) if default_name else None if explicit is not None: - # model 是档案名 → 完整采用该档案(url/key/模型名) - return explicit, explicit.get("model") or model + # model 是档案名 → 完整采用该档案(url/key/模型名); + # 档案缺 model 属配置错误,直接报错(不再把档案名当模型名发出) + _validate_profile(model, explicit) + effective = explicit.get("model") + _require_model(model, effective) + return explicit, effective if default_profile is not None: # model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖 - return default_profile, model or default_profile.get("model") + _validate_profile(default_name, default_profile) + effective = model or default_profile.get("model") + _require_model(default_name, effective) + return default_profile, effective return None, model diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py index 8bc7196..5765308 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -249,6 +249,54 @@ class TestH1KeyIsolation: assert resolve_api_key(None, _cfg(models=None)) == "sk-deepseek-leaked" +# ==================== H2:档案 schema 校验 ==================== + +class TestH2SchemaValidation: + """H2:档案缺 api_url/model 或类型错误 → 解析期立即报清晰 ValueError。 + + 此前:缺 api_url 延迟到 requests InvalidURL;缺 model 会把档案名当 + 模型名发出(或 "model": null → API 400),错误远离配置源头。 + """ + + def test_explicit_profile_missing_model(self): + """显式命中档案 + 档案缺 model → 报错(不再把档案名当模型名)""" + models = {"nomodel": {"api_url": "https://api.example/v1/chat/completions"}} + with pytest.raises(ValueError, match="nomodel.*model"): + _resolve_model_profile(_cfg(models=models, default_model=None), "nomodel") + + def test_explicit_profile_missing_api_url(self): + """显式命中档案 + 档案缺 api_url → 报错而非延迟到 InvalidURL""" + models = {"nourl": {"model": "some-model"}} + with pytest.raises(ValueError, match="nourl.*api_url"): + _resolve_model_profile(_cfg(models=models, default_model=None), "nourl") + + def test_default_profile_missing_model_no_param(self): + """默认档案缺 model + 未传 model → 报错(此前发出 "model": null)""" + models = {"deepseek": {"api_url": "https://api.deepseek.com/chat/completions"}} + with pytest.raises(ValueError, match="deepseek.*model"): + _resolve_model_profile(_cfg(models=models), None) + + def test_default_profile_missing_model_bare_name_ok(self): + """默认档案缺 model + 传裸模型名 → 裸名补位,合法""" + models = {"deepseek": {"api_url": "https://api.deepseek.com/chat/completions"}} + profile, model = _resolve_model_profile(_cfg(models=models), "deepseek-v4-lite") + assert profile is models["deepseek"] + assert model == "deepseek-v4-lite" + + def test_non_dict_profile_rejected(self): + """档案值非字典(如写成字符串)→ 报错指出实际类型""" + models = {"bad": "https://api.example/v1/chat/completions"} + with pytest.raises(ValueError, match="bad.*str"): + _resolve_model_profile(_cfg(models=models, default_model=None), "bad") + + def test_create_client_raises_on_invalid_profile(self): + """端到端:create_llm_client 对无效档案抛 ValueError(配置源头暴露)""" + models = {"kimi": {"api_url": "https://api.moonshot.cn/v1/chat/completions"}} + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + with pytest.raises(ValueError, match="kimi.*model"): + create_llm_client(_cfg(models=models, default_model=None), model="kimi") + + # ==================== DeepSeekChat 档案级 reasoning 读取 ==================== class TestChatReasoningFlag: