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:
1 parent
4480bce684
commit
0e9efe4895
3 files changed
+80
-5
No files matched your search
+2
-2
@@ -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` | 请求超时秒数 |
|
||||
|
||||
+30
-3
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user