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 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
|
- 🔒 **错误自动恢复**: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`
|
- 💻 **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
|
│ ├── 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/ # 单元测试套件(299 项全过)
|
├── tests/ # 单元测试套件(315 项全过)
|
||||||
│ ├── 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 项
|
||||||
@@ -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` 扁平节生效) |
|
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) |
|
||||||
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
|
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
|
||||||
| `timeout` | 请求超时秒数 |
|
| `timeout` | 请求超时秒数 |
|
||||||
|
| `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 |
|
||||||
|
| `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" |
|
||||||
|
|
||||||
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。
|
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。
|
||||||
|
|
||||||
`--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。
|
`--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。
|
||||||
|
|
||||||
|
**前缀逃逸**(档案名与裸模型名歧义时):`--model model:NAME` 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义);`--model profile:NAME` 强制按档案名解析(不存在则明确报错,不再静默落回裸名语义)。
|
||||||
|
|
||||||
**兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。
|
**兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。
|
||||||
|
|
||||||
其余配置节:
|
其余配置节:
|
||||||
@@ -657,7 +661,7 @@ python aibox.py [选项]
|
|||||||
--show-reasoning 一次性模式输出思考流(默认静默,走 stderr)
|
--show-reasoning 一次性模式输出思考流(默认静默,走 stderr)
|
||||||
--no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema)
|
--no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema)
|
||||||
--worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具)
|
--worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具)
|
||||||
--model NAME 模型档案名(models 节)或裸模型名(同端点换模型);服务模式与 --once 均生效(进程级覆盖)
|
--model NAME 模型档案名(models 节)或裸模型名(同端点换模型);歧义时 model:NAME 强制裸名 / profile:NAME 强制档案;服务模式与 --once 均生效(进程级覆盖)
|
||||||
--temperature T 覆盖配置的对话温度
|
--temperature T 覆盖配置的对话温度
|
||||||
--once-timeout SEC 一次性模式全局超时(默认 300 秒)
|
--once-timeout SEC 一次性模式全局超时(默认 300 秒)
|
||||||
```
|
```
|
||||||
@@ -892,7 +896,12 @@ elif command == "/mycommand":
|
|||||||
- **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏)
|
- **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏)
|
||||||
- **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型
|
- **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型
|
||||||
- **M3(reasoning 契约显式化)**:档案级 `reasoning_enabled` 从"构造后挂实例属性"改为 `LLMClient` 构造参数(三态:`True`/`False` 档案级显式、`None` 未配置回退全局),外部误设属性无从静默覆盖全局开关
|
- **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
|
### v3.3
|
||||||
- ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
|
- ✅ **安全审计与修复**(两轮审计报告见 [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:
|
try:
|
||||||
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
|
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
|
||||||
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_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 客户端(可选不同模型),独立上下文,不污染主对话
|
# 子代理使用独立的 LLM 客户端(可选不同模型),独立上下文,不污染主对话
|
||||||
sub_client = create_llm_client(config, api_key=None, logger=logger, model=model)
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": role},
|
{"role": "system", "content": role},
|
||||||
{"role": "user", "content": task}
|
{"role": "user", "content": task}
|
||||||
@@ -915,7 +918,11 @@ class DeepSeekChat:
|
|||||||
self.api_key = self.client.api_key
|
self.api_key = self.client.api_key
|
||||||
self.name = self.client.name
|
self.name = self.client.name
|
||||||
# temperature 显式传入时覆盖 constants 默认(一次性 CLI 可指定更确定性的采样)
|
# 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
|
self.history_manager = history_manager
|
||||||
# enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool,
|
# enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool,
|
||||||
# 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截
|
# 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截
|
||||||
@@ -2902,7 +2909,7 @@ def parse_args():
|
|||||||
parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库')
|
parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库')
|
||||||
parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)')
|
parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)')
|
||||||
parser.add_argument('--worker-only', action='store_true', help='一次性对话以 worker 纯执行面模式运行(7 种执行工具,无记忆/人格/派生)')
|
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,越低越确定)')
|
parser.add_argument('--temperature', type=float, metavar='T', help='覆盖默认采样温度(0.0~2.0,越低越确定)')
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|||||||
+78
-23
@@ -30,7 +30,8 @@ class LLMClient(ABC):
|
|||||||
|
|
||||||
def __init__(self, api_key: str, model: str, base_url: str,
|
def __init__(self, api_key: str, model: str, base_url: str,
|
||||||
timeout: int, config=None, logger=None,
|
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.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
@@ -39,13 +40,17 @@ class LLMClient(ABC):
|
|||||||
self.logger = logger # 可选,用于日志输出
|
self.logger = logger # 可选,用于日志输出
|
||||||
# M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled)
|
# M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled)
|
||||||
self.reasoning_enabled = 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() 强制关闭底层连接以中断阻塞读取
|
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
|
||||||
self._current_response = None
|
self._current_response = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
|
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
|
||||||
return "LLM"
|
return self._display_name or "LLM"
|
||||||
|
|
||||||
# ---------- 工具方法 ----------
|
# ---------- 工具方法 ----------
|
||||||
|
|
||||||
@@ -81,16 +86,18 @@ class LLMClient(ABC):
|
|||||||
# ---------- 抽象接口 ----------
|
# ---------- 抽象接口 ----------
|
||||||
|
|
||||||
@abstractmethod
|
@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:
|
max_tokens: int = 1024, **kwargs) -> str:
|
||||||
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。"""
|
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。
|
||||||
|
temperature=None 时按 default_temperature(档案级)→ 0.3 回退。"""
|
||||||
|
|
||||||
@abstractmethod
|
@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,
|
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||||
tool_choice: Optional[str] = None,
|
tool_choice: Optional[str] = None,
|
||||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||||
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。"""
|
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。
|
||||||
|
temperature=None 时按 default_temperature(档案级)→ 0.7 回退。"""
|
||||||
|
|
||||||
|
|
||||||
class DeepSeekClient(LLMClient):
|
class DeepSeekClient(LLMClient):
|
||||||
@@ -103,13 +110,17 @@ class DeepSeekClient(LLMClient):
|
|||||||
|
|
||||||
def __init__(self, api_key: str, model: str, base_url: str,
|
def __init__(self, api_key: str, model: str, base_url: str,
|
||||||
timeout: int = 30, config=None, logger=None,
|
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,
|
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
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "DeepSeek"
|
# L1:档案命中时展示档案名(或档案 display_name),旧扁平节保持 "DeepSeek"
|
||||||
|
return self._display_name or "DeepSeek"
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
return {
|
return {
|
||||||
@@ -117,8 +128,11 @@ class DeepSeekClient(LLMClient):
|
|||||||
"Content-Type": "application/json",
|
"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:
|
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 = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
@@ -143,10 +157,13 @@ class DeepSeekClient(LLMClient):
|
|||||||
self._log("error", f"LLM 压缩请求异常: {e}")
|
self._log("error", f"LLM 压缩请求异常: {e}")
|
||||||
return None
|
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,
|
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||||
tool_choice: Optional[str] = None,
|
tool_choice: Optional[str] = None,
|
||||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
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 = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
@@ -203,11 +220,19 @@ class DeepSeekClient(LLMClient):
|
|||||||
finally:
|
finally:
|
||||||
self._current_response = None
|
self._current_response = None
|
||||||
|
|
||||||
|
# L5:思考内容的常见厂商字段别名(首个命中的非空字段生效)
|
||||||
|
REASONING_FIELDS = ("reasoning_content", "reasoning", "thinking", "thinking_content")
|
||||||
|
|
||||||
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
|
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
|
||||||
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
|
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
|
||||||
# 思考内容
|
# 思考内容:DeepSeek reasoning_content / OpenRouter reasoning /
|
||||||
if reasoning_enabled and delta.get("reasoning_content"):
|
# 部分 GLM thinking(_content) —— 此前仅识别 DeepSeek 字段,其他厂商思考流静默丢失
|
||||||
yield {"type": "reasoning", "text": delta["reasoning_content"]}
|
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"):
|
if delta.get("content"):
|
||||||
yield {"type": "content", "text": delta["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]):
|
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 命中 models.<name> 档案 > api.default_model 档案 > None(回退旧 api 节)。
|
||||||
model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。
|
model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。
|
||||||
|
|
||||||
|
L2 前缀逃逸(档案名与裸模型名歧义时使用):
|
||||||
|
- "model:NAME" 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义)
|
||||||
|
- "profile:NAME" 强制按档案名解析(不存在则报错,不再静默落回裸名语义)
|
||||||
"""
|
"""
|
||||||
if config is None or not hasattr(config, "get"):
|
if config is None or not hasattr(config, "get"):
|
||||||
return None, model
|
return None, model, None
|
||||||
models = config.get("models") or {}
|
models = config.get("models") or {}
|
||||||
if not isinstance(models, dict):
|
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_name = config.get("api.default_model")
|
||||||
default_profile = models.get(default_name) if default_name else None
|
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)
|
_validate_profile(model, explicit)
|
||||||
effective = explicit.get("model")
|
effective = explicit.get("model")
|
||||||
_require_model(model, effective)
|
_require_model(model, effective)
|
||||||
return explicit, effective
|
return explicit, effective, model
|
||||||
if default_profile is not None:
|
if default_profile is not None:
|
||||||
# model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖
|
# model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖
|
||||||
_validate_profile(default_name, default_profile)
|
_validate_profile(default_name, default_profile)
|
||||||
effective = model or default_profile.get("model")
|
effective = model or default_profile.get("model")
|
||||||
_require_model(default_name, effective)
|
_require_model(default_name, effective)
|
||||||
return default_profile, effective
|
return default_profile, effective, default_name
|
||||||
return None, model
|
return None, model, None
|
||||||
|
|
||||||
|
|
||||||
def create_llm_client(config, api_key: Optional[str] = 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)
|
1. model 命中 models 节档案名 → 采用该档案(api_url/model/api_key_env/timeout)
|
||||||
2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名
|
2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名
|
||||||
3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model)
|
3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model)
|
||||||
|
L2 前缀逃逸:"model:NAME" 强制裸名 / "profile:NAME" 强制档案(--model 同样支持)。
|
||||||
|
|
||||||
provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient
|
provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient
|
||||||
(OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。
|
(OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。
|
||||||
@@ -335,9 +384,9 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
|||||||
config: 配置对象(需提供 get 方法)。
|
config: 配置对象(需提供 get 方法)。
|
||||||
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
|
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
|
||||||
logger: 可选日志对象。
|
logger: 可选日志对象。
|
||||||
model: 档案名或裸模型名。
|
model: 档案名或裸模型名(支持 model:/profile: 前缀逃逸)。
|
||||||
"""
|
"""
|
||||||
profile, model = _resolve_model_profile(config, model)
|
profile, model, profile_name = _resolve_model_profile(config, model)
|
||||||
|
|
||||||
env_name = None
|
env_name = None
|
||||||
allow_missing = False
|
allow_missing = False
|
||||||
@@ -372,6 +421,12 @@ def create_llm_client(config, api_key: Optional[str] = None,
|
|||||||
reasoning_enabled=(profile.get("reasoning_enabled",
|
reasoning_enabled=(profile.get("reasoning_enabled",
|
||||||
config.get("api.reasoning_enabled", False))
|
config.get("api.reasoning_enabled", False))
|
||||||
if profile is not None else None),
|
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
|
return client
|
||||||
|
|
||||||
|
|||||||
@@ -88,33 +88,38 @@ def _cfg(models=MODELS, default_model="deepseek", **api_over):
|
|||||||
class TestResolveProfile:
|
class TestResolveProfile:
|
||||||
def test_explicit_profile_name(self):
|
def test_explicit_profile_name(self):
|
||||||
"""model=档案名 → 完整采用该档案,模型名取档案内 model"""
|
"""model=档案名 → 完整采用该档案,模型名取档案内 model"""
|
||||||
profile, model = _resolve_model_profile(_cfg(), "kimi")
|
profile, model, pname = _resolve_model_profile(_cfg(), "kimi")
|
||||||
assert profile is MODELS["kimi"]
|
assert profile is MODELS["kimi"]
|
||||||
assert model == "kimi-k2"
|
assert model == "kimi-k2"
|
||||||
|
assert pname == "kimi"
|
||||||
|
|
||||||
def test_bare_model_name_uses_default_profile(self):
|
def test_bare_model_name_uses_default_profile(self):
|
||||||
"""model=裸名 → 默认档案端点 + 裸名作为模型名"""
|
"""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 profile is MODELS["deepseek"]
|
||||||
assert model == "deepseek-v3-legacy"
|
assert model == "deepseek-v3-legacy"
|
||||||
|
assert pname == "deepseek"
|
||||||
|
|
||||||
def test_no_model_uses_default_profile(self):
|
def test_no_model_uses_default_profile(self):
|
||||||
"""未指定 model → 默认档案与档案内模型名"""
|
"""未指定 model → 默认档案与档案内模型名"""
|
||||||
profile, model = _resolve_model_profile(_cfg(), None)
|
profile, model, pname = _resolve_model_profile(_cfg(), None)
|
||||||
assert profile is MODELS["deepseek"]
|
assert profile is MODELS["deepseek"]
|
||||||
assert model == "deepseek-v4-flash"
|
assert model == "deepseek-v4-flash"
|
||||||
|
assert pname == "deepseek"
|
||||||
|
|
||||||
def test_fallback_when_no_models_section(self):
|
def test_fallback_when_no_models_section(self):
|
||||||
"""无 models 节 → (None, model) 回退旧 api 扁平节"""
|
"""无 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 profile is None
|
||||||
assert model == "anything"
|
assert model == "anything"
|
||||||
|
assert pname is None
|
||||||
|
|
||||||
def test_fallback_when_default_model_missing(self):
|
def test_fallback_when_default_model_missing(self):
|
||||||
"""default_model 指向不存在的档案且 model 未命中 → 回退"""
|
"""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 profile is None
|
||||||
assert model == "whatever"
|
assert model == "whatever"
|
||||||
|
assert pname is None
|
||||||
|
|
||||||
|
|
||||||
# ==================== create_llm_client 档案构造 ====================
|
# ==================== create_llm_client 档案构造 ====================
|
||||||
@@ -279,7 +284,7 @@ class TestH2SchemaValidation:
|
|||||||
def test_default_profile_missing_model_bare_name_ok(self):
|
def test_default_profile_missing_model_bare_name_ok(self):
|
||||||
"""默认档案缺 model + 传裸模型名 → 裸名补位,合法"""
|
"""默认档案缺 model + 传裸模型名 → 裸名补位,合法"""
|
||||||
models = {"deepseek": {"api_url": "https://api.deepseek.com/chat/completions"}}
|
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 profile is models["deepseek"]
|
||||||
assert model == "deepseek-v4-lite"
|
assert model == "deepseek-v4-lite"
|
||||||
|
|
||||||
@@ -526,6 +531,159 @@ def constants_reasoning_default():
|
|||||||
return constants.REASONING_ENABLED
|
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 读取 ====================
|
# ==================== DeepSeekChat 档案级 reasoning 读取 ====================
|
||||||
|
|
||||||
class TestChatReasoningFlag:
|
class TestChatReasoningFlag:
|
||||||
|
|||||||
Reference in new issue
Block a user