From 3d7f5ca3cb5737194df39a8f74fe3400e549726d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Tue, 18 Aug 2026 00:42:57 +0800 Subject: [PATCH] feat(aibox): chat_completion cross-profile failover via fallback chain (L4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a profile declares "fallback": ["kimi", "local"], create_llm_client now returns a FailoverClient wrapper holding the ordered chain. For the idempotent non-streaming path (chat_completion — conversation compression, light subagents): - Fail over on transient errors only: HTTP 429/5xx (now raised as TransientLLMError by DeepSeekClient) and connection errors/timeouts (previously swallowed into None by chat_completion's catch-all, now re-raised). Auth/param 4xx failures keep returning None with no failover — config problems stay loud, consistent with the H1/H2 error-early philosophy. - Failed profiles enter a 60s in-process cooldown and are skipped until it expires; chain exhaustion returns None per the existing failure contract. - Config structure errors fail at construction (non-list fallback, unknown profile name, self-reference — H2-style). Environmental problems (e.g. fallback profile missing its API key) skip that fallback with a warning instead of blocking the primary. - Unspecified attributes delegate to the primary client via __getattr__ (name/model/api_key/cancel), so DeepSeekChat and set_client keep working unchanged. stream_chat still delegates to the primary directly; first-token failover is L4b. create_llm_client refactored: single-profile construction extracted into _construct_profile_client, shared by the main path and the fallback chain (flat-api path untouched). Tests: new TestL4aChatCompletionFailover (15 cases: chain build, validation, 429/5xx/connection failover, 401 no-failover, exhaustion, cooldown skip/expiry, missing-key skip with warning, transient raise, 4xx None regression, stream delegation); full suite 330 passed. --- aibox/README.md | 6 +- aibox/llm_client.py | 178 +++++++++++++++++++++++---- aibox/tests/test_models_profile.py | 185 +++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 28 deletions(-) diff --git a/aibox/README.md b/aibox/README.md index 6d81d4d..361d764 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -92,7 +92,7 @@ aibox/ │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ └── misc_tools.py # B 批:AskUserTool -├── tests/ # 单元测试套件(315 项全过) +├── tests/ # 单元测试套件(330 项全过) │ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_b_tools.py # B 批工具 58 项 @@ -553,6 +553,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i | `timeout` | 请求超时秒数 | | `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 | | `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" | +| `fallback` | 失败转移档案名列表(可选,如 `["kimi", "local"]`,一层不递归)。主档案瞬时故障(HTTP 429/5xx、连接失败/超时)时按序转移;认证/参数类 4xx **不转移**(配置问题响亮暴露);失败档案进入 60s 冷却期。当前仅 `chat_completion`(对话压缩/轻量子代理)生效,流式路径 L4b 支持 | **选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。 @@ -901,7 +902,8 @@ elif command == "/mycommand": - **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 项新增) +- ✅ **L4a(chat_completion 跨档案失败转移)**:档案新字段 `fallback`(档案名列表,一层不递归);`create_llm_client` 构造 `FailoverClient` 包装链(主档案 + fallback)。瞬时故障(HTTP 429/5xx 抛 `TransientLLMError`、连接失败/超时)按链转移,认证/参数类 4xx 不转移(配置问题响亮暴露);失败档案 60s 冷却;fallback 档案缺 Key 等环境问题跳过 + warning 不阻断主档案;配置结构错误(类型/不存在/自引用)启动期硬性拦截。当前覆盖对话压缩与轻量子代理链路,流式(`stream_chat`)转移属 L4b +- ✅ **测试**:330 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 + L4a 转移 15 项新增) ### v3.3 - ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)): diff --git a/aibox/llm_client.py b/aibox/llm_client.py index f386bf3..2d2a790 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -22,6 +22,7 @@ from typing import Dict, Iterator, List, Optional import json import os +import time import requests @@ -151,8 +152,17 @@ class DeepSeekClient(LLMClient): if response.status_code == 200: result = response.json() return result["choices"][0]["message"]["content"].strip() + if response.status_code == 429 or response.status_code >= 500: + # L4a:瞬时故障显式抛出——FailoverClient 据此转移下一档案; + # 无包装器时由调用方 catch-all 接住,下游仍走失败分支(语义等价) + raise TransientLLMError( + f"LLM 请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}") self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}") return None + except TransientLLMError: + raise # L4a:瞬时故障上抛供 FailoverClient 转移,不吞 + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + raise # L4a:连接类瞬时故障同样上抛(原被吞成 None,无法转移) except Exception as e: self._log("error", f"LLM 压缩请求异常: {e}") return None @@ -249,6 +259,86 @@ class DeepSeekClient(LLMClient): } +class TransientLLMError(RuntimeError): + """瞬时 LLM 故障(HTTP 429/5xx)——FailoverClient 据此转移下一档案(L4a)。""" + + +class FailoverClient: + """跨档案失败转移包装器(L4a)。 + + 持有序 client 链(主档案 + fallback 档案),对外实现与 LLMClient 相同的 + 消费面(未显式定义的属性经 __getattr__ 委托主 client)。 + + L4a 范围——chat_completion(非流式幂等)按链转移: + - 转移:HTTP 429/5xx(TransientLLMError)、连接失败/超时(requests 连接类异常) + - 不转移:认证/参数类 4xx(底层返回 None,直接透传——配置问题应响亮暴露) + 失败档案进入冷却期(cooldown_seconds,默认 60s),期间直接跳过, + 避免每次请求都撞刚失败的端点。链耗尽返回 None(与 chat_completion 失败契约一致)。 + + stream_chat 暂直接委托主 client(L4b 实现首 token 前转移)。 + """ + + def __init__(self, clients: List, logger=None, cooldown_seconds: float = 60.0): + self._clients = list(clients) + self.logger = logger + self.cooldown_seconds = cooldown_seconds + self._fail_at = {} # client.name → 失败时刻(time.monotonic) + + def __getattr__(self, item): + # 未显式定义的属性/方法委托主 client(name/model/api_key/cancel 等); + # 下划线属性直接报错,防 __init__ 前访问 self._clients 的无限递归 + if item.startswith("_"): + raise AttributeError(item) + return getattr(self._clients[0], item) + + def _log(self, level: str, msg: str): + if self.logger is not None and hasattr(self.logger, level): + try: + getattr(self.logger, level)(msg) + except Exception: + pass + + def _cooling(self, client) -> bool: + t = self._fail_at.get(client.name) + return t is not None and (time.monotonic() - t) < self.cooldown_seconds + + def _mark_fail(self, client): + self._fail_at[client.name] = time.monotonic() + + def chat_completion(self, messages: List[Dict], temperature: float = None, + max_tokens: int = 1024, **kwargs): + last_exc = None + tried = 0 + for client in self._clients: + if self._cooling(client): + self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过") + continue + tried += 1 + try: + result = client.chat_completion(messages, temperature=temperature, + max_tokens=max_tokens, **kwargs) + if result is not None: + return result + # None:认证/参数类 4xx 或响应异常——不可转移,直接透传 + return None + except (TransientLLMError, requests.exceptions.RequestException) as e: + last_exc = e + self._mark_fail(client) + self._log("warning", f"模型档案 {client.name} 瞬时故障({e}),转移下一档案") + self._log("error", f"全部 {len(self._clients)} 个模型档案均不可用(尝试 {tried} 个):{last_exc}") + return None + + 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]: + # L4a:暂不转移(L4b 实现首 token 前转移),直接委托主 client + yield from self._clients[0].stream_chat( + messages, temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + reasoning_enabled=reasoning_enabled, **extra) + + def resolve_api_key(api_key: Optional[str], config=None, env_name: Optional[str] = None, allow_missing: bool = False, @@ -302,6 +392,12 @@ def _validate_profile(name: str, profile) -> None: raise ValueError( f"模型档案 '{name}' 缺少 api_url 字段," f"请在 config.json 的 models.{name} 中补齐(OpenAI Chat Completions 端点 URL)") + fb = profile.get("fallback") + if fb is not None and (not isinstance(fb, list) or not all(isinstance(x, str) for x in fb)): + raise ValueError( + f"模型档案 '{name}' 的 fallback 字段无效:应为档案名字符串列表" + f"(如 [\"kimi\", \"local\"]),实际为 {type(fb).__name__}," + f"请检查 config.json 的 models.{name}") def _require_model(name: str, effective_model) -> None: @@ -367,6 +463,28 @@ def _resolve_model_profile(config, model: Optional[str]): return None, model, None +def _construct_profile_client(config, profile: Dict, profile_name: str, + effective_model: str, logger=None, + api_key: Optional[str] = None) -> LLMClient: + """L4a:按档案构造单个 DeepSeekClient(主路径与 fallback 链复用,不处理 fallback 字段)。""" + env_name = profile.get("api_key_env") + allow_missing = (env_name == "") # 档案显式空串 → 本地免密端点 + resolved_key = resolve_api_key(api_key, config, env_name=env_name, + allow_missing=allow_missing, allow_key_file=False) + return DeepSeekClient( + api_key=resolved_key, + model=effective_model, + base_url=profile.get("api_url"), + timeout=profile.get("timeout") or config.get("api.timeout", 30), + config=config, + logger=logger, + reasoning_enabled=profile.get("reasoning_enabled", + config.get("api.reasoning_enabled", False)), + display_name=profile.get("display_name") or profile_name, + default_temperature=profile.get("temperature"), + ) + + def create_llm_client(config, api_key: Optional[str] = None, logger=None, model: Optional[str] = None) -> LLMClient: """根据配置创建对应厂商的 LLM 客户端。 @@ -388,26 +506,45 @@ def create_llm_client(config, api_key: Optional[str] = None, """ profile, model, profile_name = _resolve_model_profile(config, model) - env_name = None - allow_missing = False - if profile is not None: - base_url = profile.get("api_url") - timeout = profile.get("timeout") or config.get("api.timeout", 30) - env_name = profile.get("api_key_env") - if env_name == "": - allow_missing = True # 档案显式空串 → 本地免密端点 - else: + if profile is None: + # 旧 api 扁平节(唯一允许回退 ~/.cert/deepseek.key 的路径,H1) provider = (config.get("api.provider", "deepseek") or "deepseek").lower() base_url = config.get("api.deepseek_api_url") model = model or config.get("api.deepseek_model", "deepseek-v4-flash") timeout = config.get("api.timeout", 30) - - resolved_key = resolve_api_key(api_key, config, env_name=env_name, - allow_missing=allow_missing, - allow_key_file=profile is None) - - if profile is not None: + resolved_key = resolve_api_key(api_key, config, env_name=None, + allow_missing=False, allow_key_file=True) + else: provider = "openai_compatible" + primary = _construct_profile_client(config, profile, profile_name, + model, logger=logger, api_key=api_key) + fallback_names = profile.get("fallback") or [] + if not fallback_names: + return primary + # L4a:构造 fallback 链(一层,不递归——fallback 档案自身的 fallback 字段被忽略) + models = config.get("models") or {} + chain = [primary] + for fb_name in fallback_names: + if fb_name == profile_name: + raise ValueError( + f"模型档案 '{profile_name}' 的 fallback 不能包含自身," + f"请检查 config.json 的 models.{profile_name}.fallback") + fb_profile = models.get(fb_name) + if fb_profile is None: + raise ValueError( + f"模型档案 '{profile_name}' 的 fallback 指向不存在的档案 '{fb_name}'," + f"可用档案:{', '.join(models) or '(无)'}") + try: + chain.append(_construct_profile_client( + config, fb_profile, fb_name, fb_profile.get("model"), + logger=logger)) + except RuntimeError as e: + # 环境问题(如 fallback 档案缺 API Key):跳过该 fallback 并告警, + # 不阻断主档案可用性(配置结构错误在上方已硬性拦截) + if logger is not None and hasattr(logger, "warning"): + logger.warning(f"fallback 档案 '{fb_name}' 构造失败,已忽略:{e}") + return FailoverClient(chain, logger=logger) + if provider in ("deepseek", "deepseek-chat", "openai_compatible", "openai"): client = DeepSeekClient( api_key=resolved_key, @@ -416,17 +553,6 @@ def create_llm_client(config, api_key: Optional[str] = None, timeout=timeout, config=config, logger=logger, - # M3:档案级思考模式作为构造参数(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 cab2e5a..6441e82 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -17,6 +17,7 @@ import types from unittest import mock import pytest +import requests # ---- 环境桩:保证 import aibox.py 不失败 ---- if "websockets" not in sys.modules: @@ -684,6 +685,190 @@ class TestLBatch: assert events == [{"type": "content", "text": "hello"}] +# ==================== L4a:chat_completion 跨档案失败转移 ==================== + +def _resp(status=200, text="", content="ok"): + m = mock.MagicMock() + m.status_code = status + m.text = text + m.json.return_value = {"choices": [{"message": {"content": content}}]} + return m + + +class _RecordingLogger: + def __init__(self): + self.records = [] + + def info(self, m): self.records.append(("info", m)) + def warning(self, m): self.records.append(("warning", m)) + def error(self, m): self.records.append(("error", m)) + + +class TestL4aChatCompletionFailover: + """L4a:档案带 fallback 时 chat_completion 在瞬时故障(429/5xx/连接异常)按链转移。 + + - 转移:TransientLLMError(429/5xx)与 requests 连接类异常 + - 不转移:4xx 认证/参数失败(底层返回 None 直接透传——配置问题响亮暴露) + - 失败档案进入冷却期(60s 内跳过);链耗尽返回 None + - fallback 档案构造失败(缺 Key 等环境问题)→ 跳过 + warning,不阻断主档案 + - stream_chat 转移属 L4b,本批仅验证委托主 client + """ + + FB_MODELS = None # 延迟构造(依赖 MODELS) + + def _models(self): + return { + "deepseek": dict(MODELS["deepseek"], fallback=["kimi"]), + "kimi": dict(MODELS["kimi"]), + } + + def _fc(self, models=None, env=None, logger=None): + models = models or self._models() + env = env or {"DEEPSEEK_API_KEY": "sk-ds", "MOONSHOT_API_KEY": "sk-moon"} + with mock.patch.dict(os.environ, env): + return create_llm_client(_cfg(models=models), logger=logger) + + def test_no_fallback_returns_plain_client(self): + """无 fallback 字段 → 返回普通 DeepSeekClient(零破坏)""" + from llm_client import DeepSeekClient, FailoverClient + c = self._fc(models={"deepseek": dict(MODELS["deepseek"]), "kimi": dict(MODELS["kimi"])}) + assert isinstance(c, DeepSeekClient) and not isinstance(c, FailoverClient) + + def test_fallback_returns_wrapper_with_chain(self): + """带 fallback → FailoverClient,链 = [主, fallback],属性委托主档案""" + from llm_client import FailoverClient + fc = self._fc() + assert isinstance(fc, FailoverClient) + assert [c.name for c in fc._clients] == ["deepseek", "kimi"] + assert fc.model == "deepseek-v4-flash" # __getattr__ 委托主 client + assert fc.api_key == "sk-ds" + + def test_fallback_type_validated(self): + """fallback 非字符串列表 → ValueError(H2 风格源头校验)""" + models = self._models() + models["deepseek"] = dict(models["deepseek"], fallback="kimi") + with pytest.raises(ValueError, match="fallback.*无效"): + self._fc(models=models) + + def test_fallback_unknown_profile_raises(self): + """fallback 指向不存在档案 → 报错并列出可用档案""" + models = self._models() + models["deepseek"] = dict(models["deepseek"], fallback=["nope"]) + with pytest.raises(ValueError, match="nope.*可用档案"): + self._fc(models=models) + + def test_fallback_self_reference_raises(self): + """fallback 包含自身 → 报错""" + models = self._models() + models["deepseek"] = dict(models["deepseek"], fallback=["deepseek"]) + with pytest.raises(ValueError, match="不能包含自身"): + self._fc(models=models) + + def test_failover_on_429(self): + """主档案 429 → 转移 kimi 成功;请求按链顺序发出""" + fc = self._fc() + with mock.patch("llm_client.requests.post", + side_effect=[_resp(429, "rate limited"), _resp(200, content="from-kimi")]) as mp: + result = fc.chat_completion([{"role": "user", "content": "hi"}]) + assert result == "from-kimi" + urls = [c.args[0] for c in mp.call_args_list] + assert urls[0].startswith("https://api.deepseek.com") + assert urls[1].startswith("https://api.moonshot.cn") + + def test_failover_on_5xx(self): + """主档案 503 → 转移成功""" + fc = self._fc() + with mock.patch("llm_client.requests.post", + side_effect=[_resp(503, "unavailable"), _resp(200, content="ok5")]): + assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok5" + + def test_failover_on_connection_error(self): + """主档案连接失败 → 转移成功""" + fc = self._fc() + with mock.patch("llm_client.requests.post", + side_effect=[requests.exceptions.ConnectionError("boom"), + _resp(200, content="ok-conn")]): + assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "ok-conn" + + def test_no_failover_on_auth_failure(self): + """主档案 401(不可转移失败)→ 直接 None,不调 fallback""" + fc = self._fc() + with mock.patch("llm_client.requests.post", + return_value=_resp(401, "unauthorized")) as mp: + assert fc.chat_completion([{"role": "user", "content": "hi"}]) is None + assert len(mp.call_args_list) == 1 # 只打了主档案 + + def test_chain_exhausted_returns_none(self): + """全链瞬时失败 → None(与 chat_completion 失败契约一致)""" + fc = self._fc() + with mock.patch("llm_client.requests.post", + side_effect=[_resp(429, "x"), _resp(500, "y")]) as mp: + assert fc.chat_completion([{"role": "user", "content": "hi"}]) is None + assert len(mp.call_args_list) == 2 + + def test_cooldown_skips_recently_failed(self): + """主档案失败后进入冷却:下次调用直接走 fallback;冷却过期后恢复主档案""" + import time as _time + fc = self._fc() + with mock.patch("llm_client.requests.post", + side_effect=[_resp(429, "x"), _resp(200, content="r1")]): + assert fc.chat_completion([{"role": "user", "content": "q1"}]) == "r1" + # 冷却中:第一次请求即打 kimi(deepseek 被跳过) + with mock.patch("llm_client.requests.post", + return_value=_resp(200, content="r2")) as mp: + assert fc.chat_completion([{"role": "user", "content": "q2"}]) == "r2" + assert mp.call_args_list[0].args[0].startswith("https://api.moonshot.cn") + # 冷却过期(手动回拨失败时间戳):恢复走主档案 + fc._fail_at["deepseek"] = _time.monotonic() - fc.cooldown_seconds - 1 + with mock.patch("llm_client.requests.post", + return_value=_resp(200, content="r3")) as mp: + assert fc.chat_completion([{"role": "user", "content": "q3"}]) == "r3" + assert mp.call_args_list[0].args[0].startswith("https://api.deepseek.com") + + def test_missing_key_fallback_skipped(self): + """fallback 档案缺 API Key(环境问题)→ 跳过 + warning,主档案仍可用""" + logger = _RecordingLogger() + fc = self._fc(env={"DEEPSEEK_API_KEY": "sk-ds"}, logger=logger) # 未设 MOONSHOT_API_KEY + from llm_client import FailoverClient + assert isinstance(fc, FailoverClient) + assert len(fc._clients) == 1 # kimi 被跳过 + assert any("kimi" in m for lvl, m in logger.records if lvl == "warning") + with mock.patch("llm_client.requests.post", + return_value=_resp(200, content="solo")): + assert fc.chat_completion([{"role": "user", "content": "hi"}]) == "solo" + + def test_transient_429_5xx_raise(self): + """裸 DeepSeekClient:429/502 → TransientLLMError 上抛(不被自身 catch 吞掉)""" + from llm_client import DeepSeekClient, TransientLLMError + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + for status in (429, 502): + with mock.patch("llm_client.requests.post", return_value=_resp(status, "err")): + with pytest.raises(TransientLLMError): + c.chat_completion([{"role": "user", "content": "hi"}]) + + def test_auth_4xx_returns_none_no_raise(self): + """裸 DeepSeekClient:401/400 → 返回 None 不抛(回归,响亮暴露由消费方处理)""" + from llm_client import DeepSeekClient, TransientLLMError + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + for status in (401, 400): + with mock.patch("llm_client.requests.post", return_value=_resp(status, "err")): + assert c.chat_completion([{"role": "user", "content": "hi"}]) is None + + def test_stream_chat_delegates_to_primary(self): + """L4a:stream_chat 直接委托主 client(转移属 L4b)""" + from llm_client import FailoverClient + primary = mock.MagicMock() + primary.name = "deepseek" + primary.stream_chat = mock.MagicMock( + return_value=iter([{"type": "content", "text": "x"}, {"type": "done"}])) + fc = FailoverClient([primary]) + events = list(fc.stream_chat([{"role": "user", "content": "hi"}], + temperature=0.5, max_tokens=99)) + assert events == [{"type": "content", "text": "x"}, {"type": "done"}] + kwargs = primary.stream_chat.call_args.kwargs + assert kwargs["temperature"] == 0.5 and kwargs["max_tokens"] == 99 + + # ==================== DeepSeekChat 档案级 reasoning 读取 ==================== class TestChatReasoningFlag: