feat(aibox): L-batch profile polish (display name, prefix escape, temperature, reasoning aliases)

L1 display name: client.name now resolves to the profile's
display_name field, falling back to the profile key — the dialogue
context no longer labels Kimi/Qwen sessions "DeepSeek". The legacy
flat api section keeps "DeepSeek" (zero breakage).
_resolve_model_profile now returns (profile, model, profile_name).

L2 prefix escape: "model:NAME" forces bare-model semantics even when
a profile happens to be named NAME; "profile:NAME" forces profile
semantics and raises a clear error (listing available profiles) when
missing, instead of silently falling back to bare-name behavior.
Empty prefixes are rejected. Wired through --model / create_llm_client
/ subagent model params.

L3 profile temperature: new profile field "temperature" becomes the
client's default_temperature. chat_completion/stream_chat treat
temperature=None as "use profile default, else the old hardcoded
0.3/0.7", so explicit caller values still win. DeepSeekChat and
run_light_subagent resolve CLI > profile > constants.DEFAULT_TEMPERATURE.

L5 reasoning field aliases: _parse_delta recognizes
reasoning_content / reasoning / thinking / thinking_content (first
non-empty wins), so non-DeepSeek vendors' thinking streams are no
longer silently dropped.

L4 (cross-profile failover) intentionally deferred: streaming
failover needs request-replay semantics and is feature-level work.

Tests: new TestLBatch (16 cases); 7 existing unpack sites updated to
the 3-tuple. Full suite 315 passed.
This commit is contained in:
qing committed 2026-08-18 00:06:23 +08:00
1 parent a583f44d5d
commit d29bb5504a
4 files changed
+265 -36

No files matched your search

+13 -4
View File
@@ -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)):
+10 -3
View File
@@ -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()
+78 -23
View File
@@ -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.<name> 档案 > 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
+164 -6
View File
@@ -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: