diff --git a/README.md b/README.md index e8b57df..a8f4db5 100755 --- a/README.md +++ b/README.md @@ -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 前端) diff --git a/aibox/README.md b/aibox/README.md index 4450ff4..ea4f496 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -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) diff --git a/aibox/aibox.py b/aibox/aibox.py index 49977a0..faa756f 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -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(): diff --git a/aibox/common/config.py b/aibox/common/config.py index b9be554..a63a34c 100644 --- a/aibox/common/config.py +++ b/aibox/common/config.py @@ -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, # 最大历史消息数量 diff --git a/aibox/llm_client.py b/aibox/llm_client.py index 8c88bef..250a89d 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -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..api_key_env); + None 时回退 config 的 api.api_key_env。 + allow_missing: 本地免密端点(models..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. 档案 > 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(继承其协议解析), # 或在此接入专有实现。新增厂商时在此扩展即可。 diff --git a/aibox/tests/conftest.py b/aibox/tests/conftest.py index 08381c9..d634285 100644 --- a/aibox/tests/conftest.py +++ b/aibox/tests/conftest.py @@ -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: diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py new file mode 100644 index 0000000..66a8e29 --- /dev/null +++ b/aibox/tests/test_models_profile.py @@ -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