fix(aibox): validate model profile schema at config source (H2)

Profiles previously had no validation, so config errors surfaced far
from their origin and cryptically:
- explicit profile missing `model` fell back to the profile NAME as the
  model (a profile named "local" would send "model": "local")
- default profile missing `model` with no model param produced
  "model": null -> API 400
- missing `api_url` surfaced as requests InvalidURL

_resolve_model_profile now runs _validate_profile (dict type + api_url)
before reading fields and _require_model on the effective model,
raising ValueErrors that name the profile, the missing field, and the
config.json path to fix. Bare model names still legally substitute for
a default profile's missing model field.

Tests: new TestH2SchemaValidation (6 cases: missing model/api_url,
null-model via default profile, bare-name substitution, non-dict
profile, e2e create_llm_client); full suite 282 passed.
This commit is contained in:
qing committed 2026-08-17 23:31:06 +08:00
1 parent 4480bce684
commit 0e9efe4895
3 files changed
+80 -5

No files matched your search

+48
View File
@@ -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: