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:
1 parent
5f3ca9a246
commit
a583f44d5d
4 files changed
+71
-17
No files matched your search
+4
-3
@@ -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
@@ -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
@@ -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(继承其协议解析),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user