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:
1 parent
a583f44d5d
commit
d29bb5504a
4 files changed
+265
-36
No files matched your search
+13
-4
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user