From a583f44d5d9065c216bdd52973dbbe1615011639 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 17 Aug 2026 23:58:45 +0800 Subject: [PATCH] refactor(aibox): make profile reasoning_enabled a constructor param (M3) Profile-level reasoning_enabled was attached as an instance attribute after construction only when a profile matched; the base class never declared it and consumers probed with double getattr. Any code that accidentally set client.reasoning_enabled would silently override the global switch. The flag is now an explicit LLMClient constructor parameter with three-state semantics: True/False (profile-level explicit, or the global api.reasoning_enabled value concretized when the profile omits the field) and None (no profile -> consumer falls back to global). create_llm_client passes it at construction and no longer mutates the client afterwards; DeepSeekChat._reasoning_enabled simplifies to a single getattr probe that still tolerates clients without the attr. Tests: TestM3ReasoningConstructor (4 cases) covering profile True, profile-omitted -> global concretized, base default None, and the None -> global fallback at the consumer; one legacy assertion updated to the new contract (attribute exists, value None). Full suite 299 passed. --- aibox/README.md | 7 ++-- aibox/aibox.py | 8 ++--- aibox/llm_client.py | 20 +++++++---- aibox/tests/test_models_profile.py | 53 ++++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/aibox/README.md b/aibox/README.md index bb33f6a..27113eb 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/ # 单元测试套件(295 项全过) +├── tests/ # 单元测试套件(299 项全过) │ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_b_tools.py # B 批工具 58 项 @@ -885,13 +885,14 @@ elif command == "/mycommand": ## 📄 更新日志 ### v3.4 -- ✅ **多模型档案加固(H1/H2/H3/M1/M2 五项修复)**: +- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**: - **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效 - **H2(schema 校验前移)**:档案缺 `api_url`/`model` 或类型错误时,解析期即抛出指明档案名与缺失字段的 `ValueError`——不再把档案名当模型名发出、不再延迟到 `requests InvalidURL` 或 API 400 - **H3(--model 服务模式生效)**:`--model` 升级为进程级覆盖,WebSocket 服务模式(主对话/每个会话实例/定时任务实例)与 `--once` 均生效,此前服务模式静默忽略 - **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏) - **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型 -- ✅ **测试**:295 项全过(H1 隔离 4 项 + H2 校验 6 项 + H3 传导 6 项 + M2 提示 5 项 + M1 替换 2 项新增) + - **M3(reasoning 契约显式化)**:档案级 `reasoning_enabled` 从"构造后挂实例属性"改为 `LLMClient` 构造参数(三态:`True`/`False` 档案级显式、`None` 未配置回退全局),外部误设属性无从静默覆盖全局开关 +- ✅ **测试**:299 项全过(H1 隔离 4 项 + H2 校验 6 项 + H3 传导 6 项 + M2 提示 5 项 + M1 替换 2 项 + M3 契约 4 项新增) ### 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 389c145..fc284f2 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -976,10 +976,10 @@ class DeepSeekChat: pass def _reasoning_enabled(self) -> bool: - """思考模式开关:客户端带档案级 reasoning_enabled 属性时优先(多模型档案),否则用全局配置""" - return getattr(self.client, "reasoning_enabled", None) \ - if getattr(self.client, "reasoning_enabled", None) is not None \ - else constants.REASONING_ENABLED + """思考模式开关:client.reasoning_enabled 非 None 时优先(档案级构造参数), + 否则回退全局配置;getattr 兼容未实现该契约的自定义 client""" + val = getattr(self.client, "reasoning_enabled", None) + return val if val is not None else constants.REASONING_ENABLED def _is_cancelled(self) -> bool: """检查是否已被取消。""" diff --git a/aibox/llm_client.py b/aibox/llm_client.py index 24098bf..b5aa218 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -29,13 +29,16 @@ class LLMClient(ABC): """大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。""" def __init__(self, api_key: str, model: str, base_url: str, - timeout: int, config=None, logger=None): + timeout: int, config=None, logger=None, + reasoning_enabled: bool = None): self.api_key = api_key self.model = model self.base_url = base_url self.timeout = timeout self.config = config # 可选,用于读取代理等动态配置 self.logger = logger # 可选,用于日志输出 + # M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled) + self.reasoning_enabled = reasoning_enabled # 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取 self._current_response = None @@ -99,8 +102,10 @@ class DeepSeekClient(LLMClient): """ def __init__(self, api_key: str, model: str, base_url: str, - timeout: int = 30, config=None, logger=None): - super().__init__(api_key, model, base_url, timeout, config, logger) + timeout: int = 30, config=None, logger=None, + reasoning_enabled: bool = None): + super().__init__(api_key, model, base_url, timeout, config, logger, + reasoning_enabled=reasoning_enabled) @property def name(self) -> str: @@ -362,11 +367,12 @@ 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), ) - # 档案级思考模式开关(未配置时回退全局 api.reasoning_enabled) - if profile is not None: - client.reasoning_enabled = profile.get( - "reasoning_enabled", config.get("api.reasoning_enabled", False)) return client # 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析), diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py index 9962dea..13e205d 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -161,11 +161,11 @@ class TestCreateClient: assert c.api_key == "sk-ark" assert c.base_url == "https://ark.example/v1/chat/completions" - def test_no_profile_no_reasoning_attr(self): - """无档案 → client 无 reasoning_enabled 属性(走全局配置)""" + def test_no_profile_reasoning_is_none(self): + """无档案 → reasoning_enabled=None(构造默认,消费方回退全局配置)""" with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}): c = create_llm_client(_cfg(models=None)) - assert not hasattr(c, "reasoning_enabled") + assert c.reasoning_enabled is None # ==================== resolve_api_key 档案级解析 ==================== @@ -479,6 +479,53 @@ class TestM1SetClient: fake_sub_chat.set_client.assert_called_once_with(fake_new_client) +# ==================== M3:reasoning_enabled 构造参数契约 ==================== + +class TestM3ReasoningConstructor: + """M3:档案级 reasoning_enabled 从"事后挂实例属性"改为构造参数。 + + 契约:LLMClient.reasoning_enabled 三态——True/False(档案级显式, + 含档案缺省时回退全局值的具体化)/ None(无档案,消费方回退全局)。 + """ + + def test_profile_true_via_constructor(self): + """档案显式 reasoning_enabled=True → 构造参数传入 True""" + with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}): + c = create_llm_client(_cfg(), model="kimi") + assert c.reasoning_enabled is True + + def test_profile_missing_falls_back_to_global_concrete(self): + """档案未配置该字段 → 具体化为全局 api.reasoning_enabled 的值""" + models = dict(MODELS) + models["noreason"] = { + "api_url": "https://ark.example/v1/chat/completions", + "model": "doubao-pro", + "api_key_env": "ARK_API_KEY", + } + with mock.patch.dict(os.environ, {"ARK_API_KEY": "sk-ark"}): + c = create_llm_client(_cfg(models=models), model="noreason") + assert c.reasoning_enabled is False # 全局 api.reasoning_enabled=False + + def test_base_client_default_is_none(self): + """基类构造默认 → reasoning_enabled=None(未配置语义)""" + from llm_client import DeepSeekClient + with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}): + c = DeepSeekClient(api_key="k", model="m", base_url="https://x") + assert c.reasoning_enabled is None + + def test_chat_none_falls_back_to_global(self): + """DeepSeekChat 消费端:client.reasoning_enabled=None → 全局配置""" + from test_once_mode import _make_ai + ai = _make_ai() + ai.client.reasoning_enabled = None # 显式置 None(模拟无档案契约) + assert ai._reasoning_enabled() == constants_reasoning_default() + + +def constants_reasoning_default(): + from common import constants + return constants.REASONING_ENABLED + + # ==================== DeepSeekChat 档案级 reasoning 读取 ==================== class TestChatReasoningFlag: