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.
This commit is contained in:
qing committed 2026-08-17 23:58:45 +08:00
1 parent 5f3ca9a246
commit a583f44d5d
4 files changed
+71 -17

No files matched your search

+4 -3
View File
@@ -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)):
+4 -4
View File
@@ -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:
"""检查是否已被取消。"""
+13 -7
View File
@@ -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(继承其协议解析),
+50 -3
View File
@@ -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: