feat(aibox): batch D multi-model profiles + docs

- models 配置节:多 OpenAI Chat Completions 兼容端点按档案名切换,默认空 dict 零破坏回退旧 api 节
- --model 双语义:档案名(命中 models 节完整采用)/ 裸模型名(同端点换模型)
- resolve_api_key 档案级 env 三级语义:未配置回退 api.api_key_env / 显式名称 / 空串免密(Ollama/vLLM)
- 档案级 reasoning_enabled 附加 client 属性,DeepSeekChat._reasoning_enabled 优先读取
- llm_client 增加 openai_compatible/openai provider 别名
- README 更新:aibox/README 六处(特性/配置/多模型档案专节/CLI 参数/更新日志 v3.2/测试计数 264)+ 根 README aibox 小节
- 单测 17 项新增(test_models_profile.py),全套 264 项全过
This commit is contained in:
qing committed 2026-08-17 16:58:33 +08:00
1 parent a5af4bb404
commit d03dbc7f91
7 files changed
+426 -26

No files matched your search

+1 -1
View File
@@ -235,7 +235,7 @@
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。 - 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。 - 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。 - 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换,`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
### BotOps(aibox 智能体 TUI 前端) ### BotOps(aibox 智能体 TUI 前端)
+98 -6
View File
@@ -23,6 +23,8 @@
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM - 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) - 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止 - 🛑 **可靠中断机制**:支持 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
## 🚀 快速开始 ## 🚀 快速开始
@@ -90,7 +92,7 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool │ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(232 项全过) ├── tests/ # 单元测试套件(264 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项 │ ├── test_b_tools.py # B 批工具 58 项
@@ -99,7 +101,9 @@ aibox/
│ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移) │ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移)
│ ├── test_conversation_history.py # 对话历史 │ ├── test_conversation_history.py # 对话历史
│ ├── test_conversation_compressor.py # 对话压缩器 │ ├── test_conversation_compressor.py # 对话压缩器
│ └── test_skill_manager.py # 技能管理器 │ ├── test_skill_manager.py # 技能管理器
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
│ └── test_models_profile.py # 多模型档案(Batch D 17 项)
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能) ├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
│ ├── hello-world.py │ ├── hello-world.py
│ ├── aes-crypto.py │ ├── aes-crypto.py
@@ -134,7 +138,7 @@ common ← subsystems ← tools ← aibox.py
```bash ```bash
# 从仓库根目录运行 # 从仓库根目录运行
py -m pytest aibox/tests -q py -m pytest aibox/tests -q
# 当前:232 项全过,约 11s # 当前:264 项全过
``` ```
运行期数据存放于 `~/.aibox/`: 运行期数据存放于 `~/.aibox/`:
@@ -502,15 +506,63 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"broadcast_enabled": true "broadcast_enabled": true
}, },
"api": { "api": {
"provider": "deepseek", "provider": "openai_compatible",
"deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash", "deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false, "reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
"timeout": 120, "timeout": 120,
"temperature": 0.7 "temperature": 0.7,
"default_model": "deepseek"
}, },
"context": { "models": {},
```
### 🔀 多模型档案(models 节)
`models` 可配置多个 OpenAI Chat Completions 协议兼容端点,按**档案名**切换(v3.2):
```json
{
"models": {
"kimi": {
"api_url": "https://api.moonshot.cn/v1/chat/completions",
"model": "kimi-k2",
"api_key_env": "MOONSHOT_API_KEY",
"reasoning_enabled": false,
"timeout": 120
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3:32b",
"api_key_env": ""
}
},
"api": { "default_model": "kimi" }
}
```
**档案字段**:
| 字段 | 说明 |
|---|---|
| `api_url` | 端点 URL(OpenAI Chat Completions 协议) |
| `model` | 该档案使用的模型名 |
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM) |
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
| `timeout` | 请求超时秒数 |
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。
`--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。
**兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。
其余配置节:
```json
{
"context": {
"max_history_messages": 500, "max_history_messages": 500,
"max_context_messages": 120, "max_context_messages": 120,
"max_tokens": 8192, "max_tokens": 8192,
@@ -595,6 +647,14 @@ python aibox.py [选项]
--memo-add "标题|内容|时间" 添加任务 --memo-add "标题|内容|时间" 添加任务
--memo-list 列出任务 --memo-list 列出任务
--memo-complete ID 完成任务 --memo-complete ID 完成任务
--once QUERY 一次性对话:执行 QUERY 后退出(不启动 WebSocket)
--quiet 一次性模式静默输出(stdout 仅回答正文)
--show-reasoning 一次性模式输出思考流(默认静默,走 stderr)
--no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema)
--worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具)
--model NAME 模型档案名(models 节)或裸模型名(同端点换模型)
--temperature T 覆盖配置的对话温度
--once-timeout SEC 一次性模式全局超时(默认 300 秒)
``` ```
### 使用示例 ### 使用示例
@@ -616,6 +676,30 @@ python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h"
python aibox.py --memo-list python aibox.py --memo-list
``` ```
### 💻 CLI 一次性对话(--once)
不启动 WebSocket,单次执行后退出。回答正文走 **stdout**,工具过程/思考流走 **stderr**,可直接管道组合:
```bash
# 基础问答
python aibox.py --once "帮我看下当前日期"
# 管道组合(--quiet 静默模式)
python aibox.py --once --quiet "列出当前目录 py 文件" | grep ".py" | wc -l
# 按档案名选模型 / 同端点换模型
python aibox.py --once --model kimi "总结这段代码"
python aibox.py --once --model deepseek-v4-lite "1+1?"
# 纯执行面(无人格/记忆,单次收敛,适合脚本调用)
python aibox.py --once --worker-only "执行 echo hello 并返回输出"
# 显式开启思考流 / 禁用记忆写入
python aibox.py --once --show-reasoning --no-save-memory "分析这个问题"
```
**退出码**:`0` 成功 / `1` 工具执行失败 / `2` API 调用失败 / `3` 用户中断(Ctrl+C)/ `4` 全局超时。
## 🔄 架构流程 ## 🔄 架构流程
``` ```
@@ -795,6 +879,14 @@ elif command == "/mycommand":
## 📄 更新日志 ## 📄 更新日志
### v3.2
- ✅ **CLI 一次性对话模式(`--once`)**(分三批落地,开发文档见 [docs/reports/aibox_cli_once_mode_dev_doc.md](../docs/reports/aibox_cli_once_mode_dev_doc.md)):
- **Batch A**:`--once QUERY` / `--quiet` / `--once-timeout`;不启动 WebSocket,执行后退出;回答走 stdout、过程走 stderr,可管道组合;SIGINT 与超时复用三层取消机制
- **Batch B**:`--no-save-memory`(`enable_memory` 开关干净禁用 MemoryTool)、`--show-reasoning`(思考流默认静默);压缩提示改走 output_callback 不再污染 stdout;退出码 1(工具执行失败)
- **Batch C**:SQLite WAL(多 CLI 进程并发写锁缓解)、`--worker-only`(纯执行面 + 精简 system prompt 防人格污染)、`--model` / `--temperature` 覆盖、子代理工具失败标记透传(`_SUBAGENT_TOOL_FAILED_MARKER` → orchestrator stats)
- ✅ **多模型档案(`models` 配置节)**:配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等)按档案名切换;`--model` 支持档案名与裸模型名(同端点换模型)双语义;档案级 `api_key_env` 三级语义(回退 / 显式 / 空串免密);档案级 `reasoning_enabled` 优先于全局;`models: {}` 时零破坏回退旧 `api` 节;`llm_client.py` 增加 `openai_compatible` / `openai` provider 别名
- ✅ **测试**:264 项全过(once 模式 15 项 + 多模型档案 17 项新增)
### v3.1 ### v3.1
- ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)): - ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)):
- FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61) - FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61)
+8 -2
View File
@@ -934,6 +934,12 @@ class DeepSeekChat:
except Exception: except Exception:
pass 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
def _is_cancelled(self) -> bool: def _is_cancelled(self) -> bool:
"""检查是否已被取消。""" """检查是否已被取消。"""
return self._cancel_event.is_set() return self._cancel_event.is_set()
@@ -1451,7 +1457,7 @@ class DeepSeekChat:
# 工具调用后的递归继续(is_continuation=True)时不重复发送"思考中"提示, # 工具调用后的递归继续(is_continuation=True)时不重复发送"思考中"提示,
# 因为前端已处于活跃AI状态,重复提示会导致冗余/重复输出 # 因为前端已处于活跃AI状态,重复提示会导致冗余/重复输出
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n") output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
if constants.REASONING_ENABLED: if self._reasoning_enabled():
output_callback("reasoning_start", "🤔 思考中...") output_callback("reasoning_start", "🤔 思考中...")
else: else:
# 普通模式先显示助手前缀 # 普通模式先显示助手前缀
@@ -1476,7 +1482,7 @@ class DeepSeekChat:
temperature=self.temperature, temperature=self.temperature,
max_tokens=constants.DEFAULT_MAX_TOKENS, max_tokens=constants.DEFAULT_MAX_TOKENS,
tools=tools, tools=tools,
reasoning_enabled=constants.REASONING_ENABLED, reasoning_enabled=self._reasoning_enabled(),
): ):
# 流式循环中检查取消信号 # 流式循环中检查取消信号
if self._is_cancelled(): if self._is_cancelled():
+23 -1
View File
@@ -64,14 +64,36 @@ class ConfigManager:
# 大模型调用接口 # 大模型调用接口
"api": { "api": {
"provider": "openai_compatible", # 提供商:deepseek / openai_compatible(OpenAI Chat Completions 协议兼容)
"deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL "deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL
"deepseek_model": "deepseek-v4-flash", # 模型名字 "deepseek_model": "deepseek-v4-flash", # 模型名字
"reasoning_enabled": False, # 开启思维链 "reasoning_enabled": False, # 开启思维链
"api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量 "api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量
"timeout": 120, # 调用大模型请求超时时间 "timeout": 120, # 调用大模型请求超时时间
"temperature": 0.7 # 大模型对话温度(类似思维发散程度) "temperature": 0.7, # 大模型对话温度(类似思维发散程度)
"default_model": "deepseek" # 默认模型档案名(对应 models 节的 key)
}, },
# 多模型档案:可配置多个 OpenAI Chat Completions 兼容端点,按名称选择
# 示例(在 ~/.aibox/config.json 中添加):
# "models": {
# "kimi": {
# "api_url": "https://api.moonshot.cn/v1/chat/completions",
# "model": "kimi-k2",
# "api_key_env": "MOONSHOT_API_KEY", # 未配置回退 api.api_key_env;空串 "" 为免密端点
# "reasoning_enabled": false,
# "timeout": 120
# },
# "local": {
# "api_url": "http://localhost:11434/v1/chat/completions",
# "model": "qwen3:32b",
# "api_key_env": ""
# }
# }
# 选择优先级:--model <档案名> > api.default_model > 回退旧 api 扁平节。
# 默认留空:用户未配置档案时走旧 api 节(零破坏)。
"models": {},
# 大模型上下文 # 大模型上下文
"context": { "context": {
"max_history_messages": 500, # 最大历史消息数量 "max_history_messages": 500, # 最大历史消息数量
+81 -16
View File
@@ -219,17 +219,31 @@ class DeepSeekClient(LLMClient):
} }
def resolve_api_key(api_key: Optional[str], config=None) -> str: def resolve_api_key(api_key: Optional[str], config=None,
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。""" env_name: Optional[str] = None,
allow_missing: bool = False) -> str:
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。
env_name: 档案级环境变量名(来自 models.<name>.api_key_env);
None 时回退 config 的 api.api_key_env。
allow_missing: 本地免密端点(models.<name>.api_key_env 为空串)时,
允许无 Key,返回空串(Bearer 空头,Ollama/vLLM 忽略)。
"""
if api_key: if api_key:
return api_key return api_key
env_name = None if env_name is None:
if config is not None and hasattr(config, "get"): if config is not None and hasattr(config, "get"):
env_name = config.get("api.api_key_env") env_name = config.get("api.api_key_env")
env_name = env_name or "DEEPSEEK_API_KEY" env_name = env_name or "DEEPSEEK_API_KEY"
if not env_name: # 档案显式置空 → 免密端点
if allow_missing:
return ""
raise RuntimeError("模型档案 api_key_env 配置为空且未启用免密")
env_key = os.environ.get(env_name) env_key = os.environ.get(env_name)
if env_key: if env_key:
return env_key return env_key
if allow_missing:
return ""
try: try:
home = os.environ.get("HOME", os.environ["USERPROFILE"]) home = os.environ.get("HOME", os.environ["USERPROFILE"])
key_path = os.path.join(home, ".cert", "deepseek.key") key_path = os.path.join(home, ".cert", "deepseek.key")
@@ -240,26 +254,72 @@ def resolve_api_key(api_key: Optional[str], config=None) -> str:
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key") f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
def _resolve_model_profile(config, model: Optional[str]):
"""解析模型档案。返回 (profile_dict, effective_model_name)。
优先级:model 命中 models.<name> 档案 > api.default_model 档案 > None(回退旧 api 节)。
model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。
"""
if config is None or not hasattr(config, "get"):
return None, model
models = config.get("models") or {}
if not isinstance(models, dict):
return None, model
explicit = models.get(model) if model else None
default_name = config.get("api.default_model")
default_profile = models.get(default_name) if default_name else None
if explicit is not None:
# model 是档案名 → 完整采用该档案(url/key/模型名)
return explicit, explicit.get("model") or model
if default_profile is not None:
# model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖
return default_profile, model or default_profile.get("model")
return None, model
def create_llm_client(config, api_key: Optional[str] = None, def create_llm_client(config, api_key: Optional[str] = None,
logger=None, model: Optional[str] = None) -> LLMClient: logger=None, model: Optional[str] = None) -> LLMClient:
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。 """根据配置创建对应厂商的 LLM 客户端。
模型选择优先级:
1. model 命中 models 节档案名 → 采用该档案(api_url/model/api_key_env/timeout)
2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名
3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model)
provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient
(OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。
Args: Args:
config: 配置对象(需提供 get 方法)。 config: 配置对象(需提供 get 方法)。
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。 api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
logger: 可选日志对象。 logger: 可选日志对象。
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。 model: 档案名或裸模型名。
子代理等场景可通过此参数显式指定与主代理不同的模型。
""" """
provider = (config.get("api.provider", "deepseek") or "deepseek").lower() profile, model = _resolve_model_profile(config, model)
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config) env_name = None
allow_missing = False
if profile is not None:
base_url = profile.get("api_url")
timeout = profile.get("timeout") or config.get("api.timeout", 30)
env_name = profile.get("api_key_env")
if env_name == "":
allow_missing = True # 档案显式空串 → 本地免密端点
else:
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
if provider in ("deepseek", "deepseek-chat"): resolved_key = resolve_api_key(api_key, config, env_name=env_name,
return DeepSeekClient( allow_missing=allow_missing)
if profile is not None:
provider = "openai_compatible"
if provider in ("deepseek", "deepseek-chat", "openai_compatible", "openai"):
client = DeepSeekClient(
api_key=resolved_key, api_key=resolved_key,
model=model, model=model,
base_url=base_url, base_url=base_url,
@@ -267,6 +327,11 @@ def create_llm_client(config, api_key: Optional[str] = None,
config=config, config=config,
logger=logger, logger=logger,
) )
# 档案级思考模式开关(未配置时回退全局 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(继承其协议解析), # 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
# 或在此接入专有实现。新增厂商时在此扩展即可。 # 或在此接入专有实现。新增厂商时在此扩展即可。
+2
View File
@@ -13,6 +13,8 @@ from pathlib import Path
# 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common) # 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common)
AIBOX_DIR = Path(__file__).resolve().parent.parent AIBOX_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(AIBOX_DIR)) sys.path.insert(0, str(AIBOX_DIR))
# tests 目录也加入:测试文件互相导入(如 test_models_profile 复用 test_once_mode 的 _make_ai)
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装) # 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装)
if "websockets" not in sys.modules: if "websockets" not in sys.modules:
+213
View File
@@ -0,0 +1,213 @@
"""多模型档案(models 配置节)单元测试
覆盖:
- _resolve_model_profile 三级优先级:档案名命中 > default_model 档案 > 回退 None
- 裸模型名语义:用默认档案端点、覆盖档案内模型名
- create_llm_client 档案构造:url/model/timeout 来自档案
- resolve_api_key:档案级 env 名、空串免密(allow_missing)、缺 key 报错
- 档案级 reasoning_enabled 附加到 client 并被 DeepSeekChat._reasoning_enabled 读取
- 向后兼容:无 models 节时走旧 api 扁平节
不依赖真实 API/网络:仅测构造与参数解析,不发请求。
"""
import os
import sys
import types
from unittest import mock
import pytest
# ---- 环境桩:保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
from llm_client import ( # noqa: E402
create_llm_client,
resolve_api_key,
_resolve_model_profile,
)
class FakeConfig:
"""带 get(key, default) 的最小配置桩。"""
def __init__(self, data):
self._data = data
def get(self, key, default=None):
cur = self._data
for k in key.split("."):
if not isinstance(cur, dict) or k not in cur:
return default
cur = cur[k]
return cur
MODELS = {
"deepseek": {
"api_url": "https://api.deepseek.com/chat/completions",
"model": "deepseek-v4-flash",
"api_key_env": "DEEPSEEK_API_KEY",
"timeout": 120,
},
"kimi": {
"api_url": "https://api.moonshot.cn/v1/chat/completions",
"model": "kimi-k2",
"api_key_env": "MOONSHOT_API_KEY",
"reasoning_enabled": True,
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3:32b",
"api_key_env": "", # 免密端点
"timeout": 300,
},
}
def _cfg(models=MODELS, default_model="deepseek", **api_over):
api = {
"provider": "openai_compatible",
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash",
"api_key_env": "DEEPSEEK_API_KEY",
"reasoning_enabled": False,
"timeout": 30,
"default_model": default_model,
}
api.update(api_over)
data = {"api": api}
if models is not None:
data["models"] = models
return FakeConfig(data)
# ==================== _resolve_model_profile 优先级 ====================
class TestResolveProfile:
def test_explicit_profile_name(self):
"""model=档案名 → 完整采用该档案,模型名取档案内 model"""
profile, model = _resolve_model_profile(_cfg(), "kimi")
assert profile is MODELS["kimi"]
assert model == "kimi-k2"
def test_bare_model_name_uses_default_profile(self):
"""model=裸名 → 默认档案端点 + 裸名作为模型名"""
profile, model = _resolve_model_profile(_cfg(), "deepseek-v3-legacy")
assert profile is MODELS["deepseek"]
assert model == "deepseek-v3-legacy"
def test_no_model_uses_default_profile(self):
"""未指定 model → 默认档案与档案内模型名"""
profile, model = _resolve_model_profile(_cfg(), None)
assert profile is MODELS["deepseek"]
assert model == "deepseek-v4-flash"
def test_fallback_when_no_models_section(self):
"""无 models 节 → (None, model) 回退旧 api 扁平节"""
profile, model = _resolve_model_profile(_cfg(models=None), "anything")
assert profile is None
assert model == "anything"
def test_fallback_when_default_model_missing(self):
"""default_model 指向不存在的档案且 model 未命中 → 回退"""
profile, model = _resolve_model_profile(_cfg(default_model="nope"), "whatever")
assert profile is None
assert model == "whatever"
# ==================== create_llm_client 档案构造 ====================
class TestCreateClient:
def test_profile_selects_url_model_timeout(self):
"""档案命中 → client 的 url/model/timeout 全部来自档案"""
with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}):
c = create_llm_client(_cfg(), model="kimi")
assert c.base_url == "https://api.moonshot.cn/v1/chat/completions"
assert c.model == "kimi-k2"
assert c.api_key == "sk-moon"
def test_bare_name_keeps_default_endpoint(self):
"""裸模型名 → 默认档案端点 + 裸模型名"""
with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}):
c = create_llm_client(_cfg(), model="deepseek-v4-lite")
assert c.base_url == "https://api.deepseek.com/chat/completions"
assert c.model == "deepseek-v4-lite"
def test_backward_compat_flat_api(self):
"""无 models 节 → 旧 api 扁平节行为完全不变"""
with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}):
c = create_llm_client(_cfg(models=None))
assert c.base_url == "https://api.deepseek.com/chat/completions"
assert c.model == "deepseek-v4-flash"
def test_profile_reasoning_flag_attached(self):
"""档案 reasoning_enabled → client 属性附加"""
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_without_env_falls_back_to_api_section(self):
"""档案未配置 api_key_env → 回退 api.api_key_env(用户自定义变量名兼容,回归保证)"""
models = dict(MODELS)
models["noenv"] = {
"api_url": "https://ark.example/v1/chat/completions",
"model": "doubao-pro",
}
with mock.patch.dict(os.environ, {"ARK_API_KEY": "sk-ark"}):
c = create_llm_client(_cfg(models=models, api_key_env="ARK_API_KEY"),
model="noenv")
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 属性(走全局配置)"""
with mock.patch.dict(os.environ, {"DEEPSEEK_API_KEY": "sk-ds"}):
c = create_llm_client(_cfg(models=None))
assert not hasattr(c, "reasoning_enabled")
# ==================== resolve_api_key 档案级解析 ====================
class TestResolveApiKey:
def test_profile_env_name(self):
"""档案级 api_key_env 指定环境变量"""
with mock.patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moon"}):
assert resolve_api_key(None, _cfg(), env_name="MOONSHOT_API_KEY") == "sk-moon"
def test_empty_env_name_allows_missing(self):
"""api_key_env 空串 + allow_missing → 返回空串(免密端点)"""
assert resolve_api_key(None, _cfg(), env_name="", allow_missing=True) == ""
def test_missing_key_raises(self):
"""无 key 且不允许免密 → RuntimeError"""
env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")}
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch("builtins.open", side_effect=OSError):
with pytest.raises(RuntimeError):
resolve_api_key(None, _cfg(), env_name="MOONSHOT_API_KEY")
def test_explicit_key_wins(self):
"""显式传入 api_key 优先"""
assert resolve_api_key("sk-explicit", _cfg()) == "sk-explicit"
# ==================== DeepSeekChat 档案级 reasoning 读取 ====================
class TestChatReasoningFlag:
def test_reasoning_enabled_reads_client_attr(self):
"""client 带 reasoning_enabled 属性 → DeepSeekChat._reasoning_enabled 优先读它"""
from aibox import DeepSeekChat
from test_once_mode import _make_ai # 复用 FakeLLMClient 构造
ai = _make_ai()
ai.client.reasoning_enabled = True # 档案级开
assert ai._reasoning_enabled() is True
ai.client.reasoning_enabled = False # 档案级关(覆盖全局)
assert ai._reasoning_enabled() is False
def test_reasoning_falls_back_to_global(self):
"""client 无属性 → 回退 constants.REASONING_ENABLED"""
from test_once_mode import _make_ai
ai = _make_ai()
assert ai._reasoning_enabled() == False # noqa: E712 全局默认 False