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:
1 parent
a5af4bb404
commit
d03dbc7f91
7 files changed
+426
-26
No files matched your search
@@ -235,7 +235,7 @@
|
||||
|
||||
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
|
||||
- 自带技能系统(`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 前端)
|
||||
|
||||
|
||||
+98
-6
@@ -23,6 +23,8 @@
|
||||
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
|
||||
- 🔒 **错误自动恢复**: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
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -90,7 +92,7 @@ aibox/
|
||||
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
|
||||
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy
|
||||
│ └── misc_tools.py # B 批:AskUserTool
|
||||
├── tests/ # 单元测试套件(232 项全过)
|
||||
├── tests/ # 单元测试套件(264 项全过)
|
||||
│ ├── conftest.py # sys.path 配置 + websockets 桩
|
||||
│ ├── test_basic_tools.py # A 批工具 43 项
|
||||
│ ├── test_b_tools.py # B 批工具 58 项
|
||||
@@ -99,7 +101,9 @@ aibox/
|
||||
│ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移)
|
||||
│ ├── test_conversation_history.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 技能)
|
||||
│ ├── hello-world.py
|
||||
│ ├── aes-crypto.py
|
||||
@@ -134,7 +138,7 @@ common ← subsystems ← tools ← aibox.py
|
||||
```bash
|
||||
# 从仓库根目录运行
|
||||
py -m pytest aibox/tests -q
|
||||
# 当前:232 项全过,约 11s
|
||||
# 当前:264 项全过
|
||||
```
|
||||
|
||||
运行期数据存放于 `~/.aibox/`:
|
||||
@@ -502,15 +506,63 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
"broadcast_enabled": true
|
||||
},
|
||||
"api": {
|
||||
"provider": "deepseek",
|
||||
"provider": "openai_compatible",
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"reasoning_enabled": false,
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"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_context_messages": 120,
|
||||
"max_tokens": 8192,
|
||||
@@ -595,6 +647,14 @@ python aibox.py [选项]
|
||||
--memo-add "标题|内容|时间" 添加任务
|
||||
--memo-list 列出任务
|
||||
--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
|
||||
```
|
||||
|
||||
### 💻 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
|
||||
- ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)):
|
||||
- FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61)
|
||||
|
||||
+8
-2
@@ -934,6 +934,12 @@ class DeepSeekChat:
|
||||
except Exception:
|
||||
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:
|
||||
"""检查是否已被取消。"""
|
||||
return self._cancel_event.is_set()
|
||||
@@ -1451,7 +1457,7 @@ class DeepSeekChat:
|
||||
# 工具调用后的递归继续(is_continuation=True)时不重复发送"思考中"提示,
|
||||
# 因为前端已处于活跃AI状态,重复提示会导致冗余/重复输出
|
||||
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
|
||||
if constants.REASONING_ENABLED:
|
||||
if self._reasoning_enabled():
|
||||
output_callback("reasoning_start", "🤔 思考中...")
|
||||
else:
|
||||
# 普通模式先显示助手前缀
|
||||
@@ -1476,7 +1482,7 @@ class DeepSeekChat:
|
||||
temperature=self.temperature,
|
||||
max_tokens=constants.DEFAULT_MAX_TOKENS,
|
||||
tools=tools,
|
||||
reasoning_enabled=constants.REASONING_ENABLED,
|
||||
reasoning_enabled=self._reasoning_enabled(),
|
||||
):
|
||||
# 流式循环中检查取消信号
|
||||
if self._is_cancelled():
|
||||
|
||||
+23
-1
@@ -64,14 +64,36 @@ class ConfigManager:
|
||||
|
||||
# 大模型调用接口
|
||||
"api": {
|
||||
"provider": "openai_compatible", # 提供商:deepseek / openai_compatible(OpenAI Chat Completions 协议兼容)
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL
|
||||
"deepseek_model": "deepseek-v4-flash", # 模型名字
|
||||
"reasoning_enabled": False, # 开启思维链
|
||||
"api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量
|
||||
"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": {
|
||||
"max_history_messages": 500, # 最大历史消息数量
|
||||
|
||||
+81
-16
@@ -219,17 +219,31 @@ class DeepSeekClient(LLMClient):
|
||||
}
|
||||
|
||||
|
||||
def resolve_api_key(api_key: Optional[str], config=None) -> str:
|
||||
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。"""
|
||||
def resolve_api_key(api_key: Optional[str], config=None,
|
||||
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:
|
||||
return api_key
|
||||
env_name = None
|
||||
if config is not None and hasattr(config, "get"):
|
||||
env_name = config.get("api.api_key_env")
|
||||
env_name = env_name or "DEEPSEEK_API_KEY"
|
||||
if env_name is None:
|
||||
if config is not None and hasattr(config, "get"):
|
||||
env_name = config.get("api.api_key_env")
|
||||
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)
|
||||
if env_key:
|
||||
return env_key
|
||||
if allow_missing:
|
||||
return ""
|
||||
try:
|
||||
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
||||
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")
|
||||
|
||||
|
||||
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,
|
||||
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:
|
||||
config: 配置对象(需提供 get 方法)。
|
||||
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
|
||||
logger: 可选日志对象。
|
||||
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。
|
||||
子代理等场景可通过此参数显式指定与主代理不同的模型。
|
||||
model: 档案名或裸模型名。
|
||||
"""
|
||||
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)
|
||||
profile, model = _resolve_model_profile(config, model)
|
||||
|
||||
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"):
|
||||
return DeepSeekClient(
|
||||
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
|
||||
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,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
@@ -267,6 +327,11 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
||||
config=config,
|
||||
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(继承其协议解析),
|
||||
# 或在此接入专有实现。新增厂商时在此扩展即可。
|
||||
|
||||
@@ -13,6 +13,8 @@ from pathlib import Path
|
||||
# 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common)
|
||||
AIBOX_DIR = Path(__file__).resolve().parent.parent
|
||||
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,测试环境可能未安装)
|
||||
if "websockets" not in sys.modules:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user