diff --git a/aibox/README.md b/aibox/README.md index 27113eb..6d81d4d 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -24,7 +24,7 @@ - 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) - 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止 - 💻 **CLI 一次性对话**:`--once "问题"` 不启动 WebSocket 直接执行并退出,stdout/stderr 分流可管道组合,支持 `--quiet` / `--show-reasoning` / `--worker-only` / `--model` / `--temperature` / `--once-timeout` -- 🔀 **多模型档案**:`config.json` 的 `models` 节可配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等),按名称切换,档案级 api_url/model/api_key_env/reasoning_enabled/timeout +- 🔀 **多模型档案**:`config.json` 的 `models` 节可配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等),按名称切换,档案级 api_url/model/api_key_env/reasoning_enabled/timeout/temperature/display_name;`--model` 支持 `model:`/`profile:` 前缀逃逸 ## 🚀 快速开始 @@ -92,7 +92,7 @@ aibox/ │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ └── misc_tools.py # B 批:AskUserTool -├── tests/ # 单元测试套件(299 项全过) +├── tests/ # 单元测试套件(315 项全过) │ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_b_tools.py # B 批工具 58 项 @@ -551,11 +551,15 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i | `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) | | `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) | | `timeout` | 请求超时秒数 | +| `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 | +| `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" | **选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。 `--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。 +**前缀逃逸**(档案名与裸模型名歧义时):`--model model:NAME` 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义);`--model profile:NAME` 强制按档案名解析(不存在则明确报错,不再静默落回裸名语义)。 + **兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。 其余配置节: @@ -657,7 +661,7 @@ python aibox.py [选项] --show-reasoning 一次性模式输出思考流(默认静默,走 stderr) --no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema) --worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具) - --model NAME 模型档案名(models 节)或裸模型名(同端点换模型);服务模式与 --once 均生效(进程级覆盖) + --model NAME 模型档案名(models 节)或裸模型名(同端点换模型);歧义时 model:NAME 强制裸名 / profile:NAME 强制档案;服务模式与 --once 均生效(进程级覆盖) --temperature T 覆盖配置的对话温度 --once-timeout SEC 一次性模式全局超时(默认 300 秒) ``` @@ -892,7 +896,12 @@ elif command == "/mycommand": - **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏) - **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型 - **M3(reasoning 契约显式化)**:档案级 `reasoning_enabled` 从"构造后挂实例属性"改为 `LLMClient` 构造参数(三态:`True`/`False` 档案级显式、`None` 未配置回退全局),外部误设属性无从静默覆盖全局开关 -- ✅ **测试**:299 项全过(H1 隔离 4 项 + H2 校验 6 项 + H3 传导 6 项 + M2 提示 5 项 + M1 替换 2 项 + M3 契约 4 项新增) +- ✅ **L 批体验与兼容修复(L1/L2/L3/L5 四项)**: + - **L1(展示名按档案)**:`client.name` = 档案 `display_name` > 档案名,用 Kimi/Qwen 时对话上下文不再误标 "DeepSeek";旧扁平节保持 "DeepSeek"(零破坏) + - **L2(前缀逃逸)**:`--model model:NAME` 强制裸模型名语义、`--model profile:NAME` 强制档案语义(不存在时明确报错并列出可用档案),档案名与模型名歧义可逃逸 + - **L3(档案级温度)**:档案 `temperature` 字段作为默认采样温度,优先级:CLI/调用方显式传入 > 档案字段 > 全局默认(0.7/0.3),主对话/轻量子代理/compressor 全链路生效 + - **L5(思考流字段别名)**:`_parse_delta` 识别 `reasoning_content`/`reasoning`/`thinking`/`thinking_content` 四种常见思考字段(首个命中生效),其他厂商思考流不再静默丢失 +- ✅ **测试**:315 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 项新增) ### v3.3 - ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)): diff --git a/aibox/aibox.py b/aibox/aibox.py index fc284f2..79dc297 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -585,9 +585,12 @@ def run_light_subagent(role, task, config, logger=None, model=None, try: # 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗 max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS)) - temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE # 子代理使用独立的 LLM 客户端(可选不同模型),独立上下文,不污染主对话 sub_client = create_llm_client(config, api_key=None, logger=logger, model=model) + # L3:调用方 temperature > 档案级 default_temperature > 全局默认 + client_temp = getattr(sub_client, "default_temperature", None) + temp = temperature if temperature is not None else ( + client_temp if client_temp is not None else constants.DEFAULT_TEMPERATURE) messages = [ {"role": "system", "content": role}, {"role": "user", "content": task} @@ -915,7 +918,11 @@ class DeepSeekChat: self.api_key = self.client.api_key self.name = self.client.name # temperature 显式传入时覆盖 constants 默认(一次性 CLI 可指定更确定性的采样) - self.temperature = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE + # L3:CLI/调用方 temperature > 档案级 default_temperature > 全局默认 + self.temperature = temperature if temperature is not None else ( + self.client.default_temperature + if getattr(self.client, "default_temperature", None) is not None + else constants.DEFAULT_TEMPERATURE) self.history_manager = history_manager # enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool, # 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截 @@ -2902,7 +2909,7 @@ def parse_args(): parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库') parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)') parser.add_argument('--worker-only', action='store_true', help='一次性对话以 worker 纯执行面模式运行(7 种执行工具,无记忆/人格/派生)') - parser.add_argument('--model', type=str, metavar='NAME', help='覆盖配置中的模型:档案名(models 节,如 kimi)或裸模型名(同端点换模型)。服务模式与 --once 均生效(进程级覆盖)') + parser.add_argument('--model', type=str, metavar='NAME', help='覆盖配置中的模型:档案名(models 节,如 kimi)或裸模型名(同端点换模型);歧义时可用 model:NAME 强制裸名 / profile:NAME 强制档案。服务模式与 --once 均生效(进程级覆盖)') parser.add_argument('--temperature', type=float, metavar='T', help='覆盖默认采样温度(0.0~2.0,越低越确定)') return parser.parse_args() diff --git a/aibox/llm_client.py b/aibox/llm_client.py index b5aa218..f386bf3 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -30,7 +30,8 @@ class LLMClient(ABC): def __init__(self, api_key: str, model: str, base_url: str, timeout: int, config=None, logger=None, - reasoning_enabled: bool = None): + reasoning_enabled: bool = None, + display_name: str = None, default_temperature: float = None): self.api_key = api_key self.model = model self.base_url = base_url @@ -39,13 +40,17 @@ class LLMClient(ABC): self.logger = logger # 可选,用于日志输出 # M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled) self.reasoning_enabled = reasoning_enabled + # L1:展示名(默认取档案名),业务层上下文(如 history 的 assistant 名)不再误标 "DeepSeek" + self._display_name = display_name + # L3:档案级默认采样温度;None = 未配置(方法参数未显式传入时回退硬编码默认值) + self.default_temperature = default_temperature # 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取 self._current_response = None @property def name(self) -> str: """厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。""" - return "LLM" + return self._display_name or "LLM" # ---------- 工具方法 ---------- @@ -81,16 +86,18 @@ class LLMClient(ABC): # ---------- 抽象接口 ---------- @abstractmethod - def chat_completion(self, messages: List[Dict], temperature: float = 0.3, + def chat_completion(self, messages: List[Dict], temperature: float = None, max_tokens: int = 1024, **kwargs) -> str: - """非流式对话,返回完整文本。用于对话压缩等离线摘要场景。""" + """非流式对话,返回完整文本。用于对话压缩等离线摘要场景。 + temperature=None 时按 default_temperature(档案级)→ 0.3 回退。""" @abstractmethod - def stream_chat(self, messages: List[Dict], temperature: float = 0.7, + def stream_chat(self, messages: List[Dict], temperature: float = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None, tool_choice: Optional[str] = None, reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: - """流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。""" + """流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。 + temperature=None 时按 default_temperature(档案级)→ 0.7 回退。""" class DeepSeekClient(LLMClient): @@ -103,13 +110,17 @@ class DeepSeekClient(LLMClient): def __init__(self, api_key: str, model: str, base_url: str, timeout: int = 30, config=None, logger=None, - reasoning_enabled: bool = None): + reasoning_enabled: bool = None, + display_name: str = None, default_temperature: float = None): super().__init__(api_key, model, base_url, timeout, config, logger, - reasoning_enabled=reasoning_enabled) + reasoning_enabled=reasoning_enabled, + display_name=display_name, + default_temperature=default_temperature) @property def name(self) -> str: - return "DeepSeek" + # L1:档案命中时展示档案名(或档案 display_name),旧扁平节保持 "DeepSeek" + return self._display_name or "DeepSeek" def _headers(self) -> Dict[str, str]: return { @@ -117,8 +128,11 @@ class DeepSeekClient(LLMClient): "Content-Type": "application/json", } - def chat_completion(self, messages: List[Dict], temperature: float = 0.3, + def chat_completion(self, messages: List[Dict], temperature: float = None, max_tokens: int = 1024, **kwargs) -> str: + # L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致) + if temperature is None: + temperature = self.default_temperature if self.default_temperature is not None else 0.3 payload = { "model": self.model, "messages": messages, @@ -143,10 +157,13 @@ class DeepSeekClient(LLMClient): self._log("error", f"LLM 压缩请求异常: {e}") return None - def stream_chat(self, messages: List[Dict], temperature: float = 0.7, + def stream_chat(self, messages: List[Dict], temperature: float = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None, tool_choice: Optional[str] = None, reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: + # L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致) + if temperature is None: + temperature = self.default_temperature if self.default_temperature is not None else 0.7 payload = { "model": self.model, "messages": messages, @@ -203,11 +220,19 @@ class DeepSeekClient(LLMClient): finally: self._current_response = None + # L5:思考内容的常见厂商字段别名(首个命中的非空字段生效) + REASONING_FIELDS = ("reasoning_content", "reasoning", "thinking", "thinking_content") + def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]: """将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。""" - # 思考内容 - if reasoning_enabled and delta.get("reasoning_content"): - yield {"type": "reasoning", "text": delta["reasoning_content"]} + # 思考内容:DeepSeek reasoning_content / OpenRouter reasoning / + # 部分 GLM thinking(_content) —— 此前仅识别 DeepSeek 字段,其他厂商思考流静默丢失 + if reasoning_enabled: + for field in self.REASONING_FIELDS: + value = delta.get(field) + if value: + yield {"type": "reasoning", "text": value} + break # 正文 if delta.get("content"): yield {"type": "content", "text": delta["content"]} @@ -288,18 +313,41 @@ def _require_model(name: str, effective_model) -> None: def _resolve_model_profile(config, model: Optional[str]): - """解析模型档案。返回 (profile_dict, effective_model_name)。 + """解析模型档案。返回 (profile_dict, effective_model_name, profile_name)。 优先级:model 命中 models. 档案 > api.default_model 档案 > None(回退旧 api 节)。 model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。 + + L2 前缀逃逸(档案名与裸模型名歧义时使用): + - "model:NAME" 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义) + - "profile:NAME" 强制按档案名解析(不存在则报错,不再静默落回裸名语义) """ if config is None or not hasattr(config, "get"): - return None, model + return None, model, None models = config.get("models") or {} if not isinstance(models, dict): - return None, model + return None, model, None - explicit = models.get(model) if model else None + force_bare = False + if isinstance(model, str) and ":" in model: + prefix, _, rest = model.partition(":") + if prefix == "model": + if not rest.strip(): + raise ValueError("model: 前缀后为空,请提供裸模型名,如 model:deepseek-chat") + model, force_bare = rest, True + elif prefix == "profile": + if not rest.strip(): + raise ValueError("profile: 前缀后为空,请提供档案名,如 profile:kimi") + prof = models.get(rest) + if prof is None: + raise ValueError( + f"模型档案 '{rest}' 不存在,可用档案:{', '.join(models) or '(无)'}") + _validate_profile(rest, prof) + effective = prof.get("model") + _require_model(rest, effective) + return prof, effective, rest + + explicit = models.get(model) if (model and not force_bare) else None default_name = config.get("api.default_model") default_profile = models.get(default_name) if default_name else None @@ -309,14 +357,14 @@ def _resolve_model_profile(config, model: Optional[str]): _validate_profile(model, explicit) effective = explicit.get("model") _require_model(model, effective) - return explicit, effective + return explicit, effective, model if default_profile is not None: # model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖 _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 + return default_profile, effective, default_name + return None, model, None def create_llm_client(config, api_key: Optional[str] = None, @@ -327,6 +375,7 @@ def create_llm_client(config, api_key: Optional[str] = None, 1. model 命中 models 节档案名 → 采用该档案(api_url/model/api_key_env/timeout) 2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名 3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model) + L2 前缀逃逸:"model:NAME" 强制裸名 / "profile:NAME" 强制档案(--model 同样支持)。 provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient (OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。 @@ -335,9 +384,9 @@ def create_llm_client(config, api_key: Optional[str] = None, config: 配置对象(需提供 get 方法)。 api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。 logger: 可选日志对象。 - model: 档案名或裸模型名。 + model: 档案名或裸模型名(支持 model:/profile: 前缀逃逸)。 """ - profile, model = _resolve_model_profile(config, model) + profile, model, profile_name = _resolve_model_profile(config, model) env_name = None allow_missing = False @@ -372,6 +421,12 @@ def create_llm_client(config, api_key: Optional[str] = None, reasoning_enabled=(profile.get("reasoning_enabled", config.get("api.reasoning_enabled", False)) if profile is not None else None), + # L1:展示名——档案 display_name > 档案名;旧扁平节 None → "DeepSeek" + display_name=(profile.get("display_name") or profile_name + if profile is not None else None), + # L3:档案级默认采样温度(档案 temperature 字段,未配置为 None) + default_temperature=(profile.get("temperature") + if profile is not None else None), ) return client diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py index 13e205d..cab2e5a 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -88,33 +88,38 @@ def _cfg(models=MODELS, default_model="deepseek", **api_over): class TestResolveProfile: def test_explicit_profile_name(self): """model=档案名 → 完整采用该档案,模型名取档案内 model""" - profile, model = _resolve_model_profile(_cfg(), "kimi") + profile, model, pname = _resolve_model_profile(_cfg(), "kimi") assert profile is MODELS["kimi"] assert model == "kimi-k2" + assert pname == "kimi" def test_bare_model_name_uses_default_profile(self): """model=裸名 → 默认档案端点 + 裸名作为模型名""" - profile, model = _resolve_model_profile(_cfg(), "deepseek-v3-legacy") + profile, model, pname = _resolve_model_profile(_cfg(), "deepseek-v3-legacy") assert profile is MODELS["deepseek"] assert model == "deepseek-v3-legacy" + assert pname == "deepseek" def test_no_model_uses_default_profile(self): """未指定 model → 默认档案与档案内模型名""" - profile, model = _resolve_model_profile(_cfg(), None) + profile, model, pname = _resolve_model_profile(_cfg(), None) assert profile is MODELS["deepseek"] assert model == "deepseek-v4-flash" + assert pname == "deepseek" def test_fallback_when_no_models_section(self): """无 models 节 → (None, model) 回退旧 api 扁平节""" - profile, model = _resolve_model_profile(_cfg(models=None), "anything") + profile, model, pname = _resolve_model_profile(_cfg(models=None), "anything") assert profile is None assert model == "anything" + assert pname is None def test_fallback_when_default_model_missing(self): """default_model 指向不存在的档案且 model 未命中 → 回退""" - profile, model = _resolve_model_profile(_cfg(default_model="nope"), "whatever") + profile, model, pname = _resolve_model_profile(_cfg(default_model="nope"), "whatever") assert profile is None assert model == "whatever" + assert pname is None # ==================== create_llm_client 档案构造 ==================== @@ -279,7 +284,7 @@ class TestH2SchemaValidation: 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") + profile, model, _ = _resolve_model_profile(_cfg(models=models), "deepseek-v4-lite") assert profile is models["deepseek"] assert model == "deepseek-v4-lite" @@ -526,6 +531,159 @@ def constants_reasoning_default(): return constants.REASONING_ENABLED +# ==================== L 批:展示名/前缀逃逸/档案温度/思考字段别名 ==================== + +class TestLBatch: + """L1 展示名 / L2 model:-profile: 前缀逃逸 / L3 档案级 temperature / L5 思考流字段别名""" + + # ---------- L1:client.name 按档案展示 ---------- + + def test_l1_name_is_profile_name(self): + """档案命中 → client.name = 档案名(不再恒为 DeepSeek)""" + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + c = create_llm_client(_cfg(), model="kimi") + assert c.name == "kimi" + + def test_l1_display_name_override(self): + """档案 display_name 字段 > 档案名""" + models = dict(MODELS) + models["kimi"] = dict(models["kimi"], display_name="Kimi K2") + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + c = create_llm_client(_cfg(models=models), model="kimi") + assert c.name == "Kimi K2" + + def test_l1_flat_api_keeps_deepseek(self): + """旧扁平节(无档案)→ name 保持 DeepSeek(零破坏)""" + with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}): + c = create_llm_client(_cfg(models=None)) + assert c.name == "DeepSeek" + + def test_l1_bare_name_uses_default_profile_name(self): + """裸模型名走默认档案 → name = 默认档案名""" + with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}): + c = create_llm_client(_cfg(), model="deepseek-v4-lite") + assert c.name == "deepseek" + + # ---------- L2:前缀逃逸 ---------- + + def test_l2_model_prefix_forces_bare(self): + """档案恰好叫 deepseek-chat 时 model:deepseek-chat 仍走裸名语义""" + models = { + "deepseek-chat": dict(MODELS["deepseek"]), + "kimi": dict(MODELS["kimi"]), + } + profile, model, pname = _resolve_model_profile( + _cfg(models=models, default_model="kimi"), "model:deepseek-chat") + assert profile is models["kimi"] # 默认档案端点 + assert model == "deepseek-chat" # 裸名作模型名 + assert pname == "kimi" + + def test_l2_profile_prefix_forces_profile(self): + """profile:kimi 强制档案语义(即便存在同名裸模型的歧义)""" + profile, model, pname = _resolve_model_profile(_cfg(), "profile:kimi") + assert profile is MODELS["kimi"] + assert model == "kimi-k2" + assert pname == "kimi" + + def test_l2_profile_prefix_missing_raises(self): + """profile:不存在的档案 → 明确报错(不再静默落回裸名语义)""" + with pytest.raises(ValueError, match="不存在"): + _resolve_model_profile(_cfg(), "profile:nonexistent") + + def test_l2_empty_prefix_raises(self): + """前缀后为空 → 报错""" + with pytest.raises(ValueError, match="前缀后为空"): + _resolve_model_profile(_cfg(), "model:") + with pytest.raises(ValueError, match="前缀后为空"): + _resolve_model_profile(_cfg(), "profile:") + + def test_l2_prefix_via_create_client(self): + """端到端:--model model:NAME / profile:NAME 经 create_llm_client 生效""" + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon", + "DEEPSEEK_API_KEY": "sk-ds"}): + c1 = create_llm_client(_cfg(), model="model:deepseek-v4-lite") + assert c1.model == "deepseek-v4-lite" and c1.name == "deepseek" + c2 = create_llm_client(_cfg(), model="profile:kimi") + assert c2.model == "kimi-k2" and c2.name == "kimi" + + # ---------- L3:档案级 temperature ---------- + + def test_l3_profile_temperature_default(self): + """档案 temperature → client.default_temperature;chat_completion 未传参时生效""" + models = dict(MODELS) + models["kimi"] = dict(models["kimi"], temperature=0.15) + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + c = create_llm_client(_cfg(models=models), model="kimi") + assert c.default_temperature == 0.15 + + def test_l3_no_temperature_keeps_none(self): + """档案未配置 temperature → default_temperature=None(旧硬编码默认)""" + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + c = create_llm_client(_cfg(), model="kimi") + assert c.default_temperature is None + + def test_l3_chat_completion_resolves_default(self): + """chat_completion(temperature=None) → 档案温度;显式传参仍优先""" + from llm_client import DeepSeekClient + c = DeepSeekClient(api_key="k", model="m", base_url="https://x", + default_temperature=0.15) + captured = {} + with mock.patch("llm_client.requests.post", + return_value=mock.MagicMock( + status_code=200, + json=lambda: {"choices": [{"message": {"content": "ok"}}]})) as mp: + c.chat_completion([{"role": "user", "content": "hi"}]) + captured["default"] = mp.call_args.kwargs["json"]["temperature"] + c.chat_completion([{"role": "user", "content": "hi"}], temperature=0.9) + captured["explicit"] = mp.call_args.kwargs["json"]["temperature"] + assert captured["default"] == 0.15 + assert captured["explicit"] == 0.9 + + def test_l3_chat_completion_hardcoded_default(self): + """无档案温度 + 未传参 → 0.3(与旧版行为一致)""" + from llm_client import DeepSeekClient + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + with mock.patch("llm_client.requests.post", + return_value=mock.MagicMock( + status_code=200, + json=lambda: {"choices": [{"message": {"content": "ok"}}]})) as mp: + c.chat_completion([{"role": "user", "content": "hi"}]) + assert mp.call_args.kwargs["json"]["temperature"] == 0.3 + + # ---------- L5:思考流字段别名 ---------- + + def test_l5_reasoning_field_aliases(self): + """reasoning/thinking/thinking_content 字段均归一化为 reasoning 事件""" + from llm_client import DeepSeekClient + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + cases = [ + {"reasoning_content": "a"}, + {"reasoning": "b"}, + {"thinking": "c"}, + {"thinking_content": "d"}, + ] + for delta in cases: + events = list(c._parse_delta(delta, reasoning_enabled=True)) + assert events == [{"type": "reasoning", + "text": list(delta.values())[0]}], delta + + def test_l5_first_present_field_wins(self): + """多字段并存 → 首个命中别名生效,不重复产出""" + from llm_client import DeepSeekClient + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + events = list(c._parse_delta( + {"reasoning_content": "x", "reasoning": "y"}, reasoning_enabled=True)) + assert events == [{"type": "reasoning", "text": "x"}] + + def test_l5_reasoning_disabled_ignores_all(self): + """reasoning_enabled=False → 别名字段一律不产出(正文不受影响)""" + from llm_client import DeepSeekClient + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + events = list(c._parse_delta( + {"reasoning_content": "x", "content": "hello"}, reasoning_enabled=False)) + assert events == [{"type": "content", "text": "hello"}] + + # ==================== DeepSeekChat 档案级 reasoning 读取 ==================== class TestChatReasoningFlag: