From 781579bf36f2f5e876acae1de643f004debb487a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 05:42:05 -0400 Subject: [PATCH 1/7] docs: record thinking-switch findings and capability design (issue #5, #6) Findings: live-API measurements across MiniMax M3/M2.7/M2.5, qwen and deepseek, plus a survey of how nine unified gateways model per-model parameter divergence. Key facts: reasoning_effort is MiniMax's real switch, M2.x reasoning is mandatory and cannot be disabled, and the relay's local token-count fallback silently drops reasoning_tokens. Design: keep the parameter shape at provider level, push capability down to model level, split "unknown" / "unsupported" / "no opinion" into three distinct values, and fail at assembly time when a model cannot honour enable_thinking=False. --- .../2026-08-02-thinking-capability-design.md | 258 ++++++++++++++++++ ...02-thinking-switch-and-reasoning-tokens.md | 179 ++++++++++++ research-wiki/graph/edges.json | 7 + research-wiki/index.md | 8 +- research-wiki/log.md | 5 + research-wiki/query_pack.md | 2 +- 6 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 research-wiki/designs/2026-08-02-thinking-capability-design.md create mode 100644 research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md diff --git a/research-wiki/designs/2026-08-02-thinking-capability-design.md b/research-wiki/designs/2026-08-02-thinking-capability-design.md new file mode 100644 index 0000000..8050ac4 --- /dev/null +++ b/research-wiki/designs/2026-08-02-thinking-capability-design.md @@ -0,0 +1,258 @@ +--- +type: design +node_id: design:2026-08-02-thinking-capability-design +title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)" +date: 2026-08-02 +--- + +# 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6) + +> 类型:design|日期:2026-08-02|状态:待人类确认 +> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`(本文所有实测引用均出自该文)。 +> 本设计经 2026-08-02 充分讨论后直接给出单一方案,不列备选。 + +## 1. 问题 + +**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关,靠 `providers.py` 的 `ProviderProfile.thinking_on/thinking_off` 落地。`minimax` 与 `openai` 两格皆为空 dict,`_build_payload` 的 `payload.update({})` 是空操作:`enable_thinking=False` 对这两类源**完全不产生效果**,而配置方以为关掉了。 + +这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`,并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。 + +**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确(推理 token 已含在 `completion_tokens` 内),但"本次调用有多少钱花在推理上"无法区分,而这正是 dissect 要测的因子的主要成本通道。 + +**两者的耦合。** #6 是 #5 的验收仪器:修完 #5 后判断"这次是否真的没推理",靠正文长度不可靠,靠 `reasoning_content` 也不行(MiniMax 非流式恒为空、正文无 `` 标签)。因此 **#6 先落地,#5 的测试断言它**。 + +## 2. 根因 + +空 dict 同时承载了两种语义:「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同,就只能靠"表里没有 = 不发"兜底,静默失效随之产生。 + +更深一层:`ProviderProfile` 的注册单位是 **provider**,而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关,M2.7 / M2.5 **固有不可关**(三种参数形态实测全部无效,OpenRouter 与 models.dev 独立登记为 mandatory)。provider 级的表在物理上表达不了这件事。 + +业界佐证:注册单位下沉到 model 级的(LiteLLM、models.dev、LangChain、OpenRouter、Helicone)都有显式失败通道;仍停在 provider 级的(Portkey、LlamaIndex)恰是失败语义最差的两家,均静默丢弃。**注册粒度与失败语义是同一个问题的两面。** + +## 3. 决策摘要 + +| # | 决策 | +|---|---| +| D1 | **形态留 provider 级,能力下沉 model 级**。形态 = 参数长什么样(数年不变);能力 = 能否关闭(每代都变) | +| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**,落在三个不同层次 | +| D3 | **遇到"关不掉"的模型报错,不静默放行**;报错在装配期,请求期兜底 | +| D4 | **「开」的默认档定 `medium`,允许 per-source 覆盖**(经已有 `extra_body`,不新增字段) | +| D5 | `enable_thinking` **纳入缓存指纹**(配套,必做) | +| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」,非「该源未上报」(配套,必做) | + +D4 的依据:业界对「开」映射到哪一档**无语义共识**(LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半),唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定,`medium` 是五档中语义最接近"厂商正常强度"的一档;选 `high` 等于库替所有下游做"加钱换质量"的业务判断,违反零业务假设。 + +## 4. 数据模型 + +### 4.1 形态层(provider 级) + +`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`: + +| 值 | 含义 | 当前实例 | +|---|---|---| +| `{...}` | 已知的注入片段 | qwen / deepseek / minimax | +| `{}` | 已知**无需注入**即处于该档 | 无(保留为自然零值) | +| `None` | **未知**:库不知道该 provider 如何表达 | `openai` 两档 | + +```python +"minimax": ProviderProfile( + name="minimax", + thinking_on={"reasoning_effort": "medium"}, + thinking_off={"reasoning_effort": "none"}, + strip_think_tags=False, +), +"openai": ProviderProfile( + name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False, +), +``` + +`openai` 填 `None` 而非补 `reasoning_effort`,理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**(`dissect/.env:116` 把 `kimi-k3` 挂在 `provider=openai` 下)。向未知厂商下发 `reasoning_effort` 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。 + +qwen / deepseek 两条实测正确,**不动**。 + +### 4.2 能力层(model 级,新增) + +```python +@dataclass(frozen=True) +class ThinkingCapability: + """某个具体模型的推理能力(model 级);登记必须附实测证据与日期。""" + can_disable: bool + evidence: str +``` + +登记表键为模型名精确匹配,**只登记在用的模型**,未登记即"未知"并走退化路径: + +| 模型 | `can_disable` | 证据 | +|---|---|---| +| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 | +| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter `mandatory:true` | +| `MiniMax-M2.5` | `False` | 同上 | +| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 | +| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 | + +注入方式沿用 D11 的纯函数注册纪律:`get_capability(model, *, table=None)` 与 `register_capability(...)` 返回新表,经 `capabilities` 参数注入,与现有 `registry` 参数同形,**不引入模块级可变状态**。 + +**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立(import 期发网络请求)。二者仅作为写表时的对照参考;本次三条 MiniMax 实测与它们的登记 100% 吻合,这本身就是表可信的旁证。 + +### 4.3 三个值的层次归属(D2) + +| 语义 | 载体 | 层次 | +|---|---|---| +| **不干预**(调用方不表态) | `SourceConfig.enable_thinking is None` | 调用方意图 | +| **未知**(库不知道怎么表达) | `ProviderProfile` 该档为 `None` | 形态层 | +| **不支持**(模型做不到) | `ThinkingCapability.can_disable is False` | 能力层 | + +三者不可互相替代:不干预是意图缺失,未知是知识缺失,不支持是能力缺失。当前实现把后两者塌缩成空 dict,是 issue #5 的根因。 + +## 5. 判定与失败语义(D3) + +单一判定函数收口,形态层与能力层在此相遇: + +```python +def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]: + """三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。""" +``` + +真值表: + +| # | 条件 | 行为 | +|---|---|---| +| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 | +| R2 | 形态层该档为 `None` | **报错**,文案指路 `register_provider` 或 `extra_body` | +| R3 | `enable_thinking is False` 且 `can_disable is False` | **报错**:调用方要的是"不推理"的语义保证,给不了必须说 | +| R4 | 模型未登记(能力未知) | 按形态层注入 + `loguru.warning`,不阻断 | +| R5 | 其余 | 按形态层注入 | + +R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律:**"关不掉"用错的后果是下游带着错误前提做实验(opt-in,从严);"未登记"多为新模型上线(opt-out,从宽)**,误拒会让库成为升级路上的绊脚石。 + +### 5.1 报错位置:两处,共用同一份判定 + +| 位置 | 异常 | 覆盖 | +|---|---|---| +| `client.py:from_settings`(`:248` 已在此解析 profiles) | `ValueError`(装配期) | `from_env` / `from_settings` 两条工厂路径,即 90% 场景 | +| `OpenAICompatTransport` | `RequestRejectedError`(四分类之一,不重试不换源) | 构造函数全量注入路径 | + +这不是重复判定:`get_provider` 现在就是同一形态(`client.py:248` + `openai_compat.py:313`)。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。 + +**绝不在 `_build_payload` 里抛裸 `ValueError`**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。 + +## 6. reasoning_tokens 采集(issue #6) + +照搬 issue #3 的 `_coerce_cached_tokens` 形态:只收非负整数,显式排除 `bool`(`isinstance(True, int)` 为真,放行会把 `True` 记成 1)。 + +`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺(`types.py:1-5`),只增不删不改名。 + +流式与非流式对称取值:`completion_tokens_details` 在最后的 usage 帧里,`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`(现有代码天然满足:`sink` 无 usage 时 `_coerce_*` 返回 `None`)。 + +**`pricing.py` 一行不改**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。这是归因缺口,不是计费缺口。 + +**缓存路径无需改动**:`CacheMW._rehydrate` 按 `_RESPONSE_FIELDS` 动态过滤(`cache.py:28,133`),旧条目缺该字段自动落 `None`,语义正确。 + +### 6.1 语义澄清(D6) + +实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**,无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage,把 ctd 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。因此: + +- docstring 写「**本次调用**未上报」,**不可**写「该源未上报」 +- 下游判据必须是 `reasoning_tokens in (None, 0)`,写 `== 0` 的条件永远不成立 +- 这三句要同时进 docstring、CHANGELOG 与 wiki + +## 7. 缓存指纹配套(D5) + +`build_model_fingerprint`(`client.py:63-80`)当前只摘要 `(model, extra_body)`。#5 一旦让 thinking 真正改变请求体,就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4 为 `temperature` 写过逐字相同的理由。 + +做法:marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`,摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同,不触发存量缓存冷启动**;dissect 会有一次性冷启动,这是正确行为(旧缓存来自推理开着的调用)。 + +## 8. 落点清单 + +| 文件 | 改动 | +|---|---| +| `providers.py` | 两档放宽为 `dict \| None`;填 minimax、`openai` 改 `None`;新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` | +| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`;新增 `_coerce_reasoning_tokens`;流式 `:401` 与非流式 `:485` 填值;构造函数收 `capabilities` | +| `client.py` | `from_settings` / `from_env` 加 `capabilities`;`:248` 后加装配守卫;`build_model_fingerprint` 纳入 `enable_thinking` | +| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` | +| `middleware/retry.py` | `_build_response` 透传 | +| `ports.py` | `record_llm_call` 21 → 22 字段 | +| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`,**新列排末尾**(两处注释均有明文要求) | +| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 | + +## 9. 测试策略 + +本次改动的正确性**与具体模型强相关**,mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。 + +### 9.1 三层分工 + +| 层 | 内容 | 是否门控合并 | +|---|---|---| +| unit | `resolve_thinking` 真值表(R1–R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**(CI 可跑) | +| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** | +| **e2e(真实 API)** | 见 9.2 | 不进 CI 自动门,但**合并前必须真跑并存档报告** | + +e2e 不进 CI 自动门的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404。让外部波动阻断合并,会把测试变成噪声源。但"不自动门控"不等于"可跳过"——沿用项目既有 e2e 的口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着"验收前必须真跑")。 + +### 9.2 e2e 覆盖矩阵 + +沿用既有 e2e 约定:`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`,结构化报告输出至 `tests/outputs/e2e/`。 + +| # | 场景 | 源 | 轮数 | 判据 | +|---|---|---|---|---| +| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30` 且 `reasoning_tokens` 恒 `None` | +| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` | +| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数(基线) | +| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`,profile 的 `medium` 被覆盖 | +| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2(库默认 `stream=True`,这是主路径) | +| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 | +| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 | +| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 | +| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地,无需真实调用 | + +轮数由环境变量可调高,默认 ≥10。总量约 100–150 次调用。 + +### 9.3 三条必须遵守的测试纪律 + +**(a)主判据选不会被中转污染的量。** `reasoning_tokens` 会被 new-api 的本地补算吃掉(实测 6:4 随机),单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。因此**"是否关闭"的主判据用 `completion_tokens` 阈值,`reasoning_tokens` 作辅助**。这是本次实测最重要的工程教训之一。 + +**(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。 + +**(c)源不可用必须跳过并显式记录为"未覆盖",不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。 + +### 9.4 漂移哨兵(L8)的定位 + +能力表过期是必然事件(LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故)。L8 用真实调用反向校验每条登记,是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真,这里会先炸。建议纳入发版前清单定期执行。 + +## 10. 明确不做 + +不为中转的观测漂移在库内加任何机制(多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`)——中转路由不受请求参数影响,探测结果不可迁移,属 YAGNI 违规;该问题在运维侧解决,写入 wiki 前提。 + +不改 `SourceConfig` 的公开字段形态:`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`,两条路径已进缓存 key 与 `sampling` 遥测列,新增字段则要额外接这两处,是隐藏成本。 + +不动 qwen / deepseek 的 profile;不碰 `pricing.py`;不引入任何新依赖。 + +## 11. 验收标准 + +1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`,响应 `reasoning_tokens is None`,真实 API 多轮验证 +2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**,文案说明该模型无法关闭推理 +3. `ENABLE_THINKING` 任意非 `None` + `provider=openai` → **装配期报错**,指路 `register_provider` / `extra_body` +4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning +5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入 +6. 流式与非流式均能采到 `reasoning_tokens`;打捞路径记 `None` 而非 `0` +7. 改 `enable_thinking` → 缓存 key 变化;不配该项的存量 scope key 逐字不变 +8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写 +9. e2e 报告存档于 `tests/outputs/e2e/`,矩阵覆盖情况可核 + +每条均需"先失败后通过"的证据(测试结果门)。 + +## 12. 影响与风险 + +**这是行为变更,不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效",CHANGELOG 须醒目标注;dissect 会有一次性缓存冷启动。 + +**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性,任何库层改动都无法改变。可行替代是只在 M3 上做该对照,或将因子改为"高档 vs 低档"。此结论须同步给 dissect。 + +**能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。 + +**三个下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。新增的失败面仅有 `provider=openai` + 配了 `ENABLE_THINKING` 这一组合,经全仓与 dissect 检索当前无此用法。 + +## 13. 另立 issue(不在本次范围) + +`kimi-k3` 拒绝 `temperature=0`(400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。 + +`qwen` 的 `strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `` 标签),无害死代码,可顺带清理或另记。 diff --git a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md new file mode 100644 index 0000000..594350d --- /dev/null +++ b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md @@ -0,0 +1,179 @@ +--- +type: finding +node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens +title: "推理开关与 reasoning_tokens: 供应商实测与业界做法" +date: 2026-08-02 +--- + +# 推理开关与 reasoning_tokens:供应商实测与业界做法 + +> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研 +> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。 +> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。 + +## 1. 实验环境与方法 + +| 项 | 值 | +|---|---| +| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) | +| 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 | +| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" | +| 判据 | 首选 `usage.completion_tokens_details.reasoning_tokens`;该字段缺失时以 `completion_tokens` 兜底(关闭推理应 <30,推理中 >150) | +| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 | + +**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。 + +## 2. MiniMax:真开关是 `reasoning_effort` + +### 2.1 M3 参数矩阵(非流式) + +| 注入参数 | prompt | completion | reasoning_tokens | 判定 | +|---|---|---|---|---| +| 默认(不传) | 194 | 4 | 无 ctd | 不推理 | +| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 | +| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 | +| `reasoning_effort=low` | **207** | 98 | 93 | 推理 | +| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 | +| `reasoning_effort=high` | **207** | 158 | 142 | 推理 | +| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** | +| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** | +| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** | +| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** | + +`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。 + +### 2.2 `none` 是被识别的真值,不是被当非法值丢弃 + +这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。 + +反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。 + +对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。 + +### 2.3 M2.7 / M2.5 的推理关不掉 + +三种参数形态各 3 次,`completion_tokens` 全部落在推理区间: + +| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` | +|---|---|---|---|---| +| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 | +| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 | + +真关闭应为 5–10("23 12" 两个数字),实测无一接近。 + +**三个独立外部来源与实测完全吻合**: + +| 来源 | M3 | M2.7 / M2.5 | +|---|---|---| +| OpenRouter `/api/v1/models` 的 `reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** | +| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) | +| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 | + +**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。 + +### 2.4 M3 的稳定性 + +同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。 + +## 3. qwen / deepseek:现有 profile 正确 + +| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile | +|---|---|---|---|---| +| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | `enable_thinking` — **正确** | +| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | `thinking:{type}` — **正确** | + +两点附带事实: + +- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。 +- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `` 标签。无害,但属于死代码。 +- **非流式没有 400**:DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。 + +## 4. new-api 中转的三个行为(会污染观测) + +这一节对任何经中转做实测的场景都适用,值得单独记住。 + +**(a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。 + +**(b)静默丢弃未知键。** 默认路径是 struct round-trip(`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。 + +**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象: + +| 现象 | 解释 | +|---|---| +| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens`,`prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd | + +**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。 + +**对库的直接影响**:`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。 + +## 5. 业界如何建模"同一语义、形态因模型而异" + +调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。 + +### 5.1 核心共识:形态按 provider,能力按 model + +| 概念 | 变化频率 | 应归属层次 | +|---|---|---| +| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 | +| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** | + +注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。 + +### 5.2 失败语义的四种谱系 + +| 语义 | 代表 | 适用前提 | +|---|---|---| +| 默认报错 + 可配置降级开关 | LiteLLM(`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 | +| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]`) | 调用方愿意读 warning | +| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 | +| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** | + +**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。 + +### 5.3 表会过期,这是公理 + +LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴: + +- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝 +- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒 + +维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。** + +### 5.4 「布尔开关 → 多档旋钮」无语义共识 + +| 系统 | effort → 预算的换算 | +|---|---| +| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 | +| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20%) | +| Helicone | 一律 `max_tokens/2`,完全不看档位 | +| LangChain | 明确不保证跨 provider 可比 | + +**唯一对齐的是"关"**:`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。 + +**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。 + +### 5.5 Vercel AI SDK 的一处设计值得单记 + +它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。 + +## 6. 附带发现(不属本次范围,建议另立 issue) + +**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。 + +**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。 + +## 7. 未能证实 + +1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。 +2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。 +3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。 +4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。 + +## 8. 对后续开发的指导 + +1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。 +2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。 +3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。 +4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。 +5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。 +6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 2e98396..d7b0e18 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -223,6 +223,13 @@ "relation": "implements", "evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单", "added": "2026-07-31T16:59:35.657367+00:00" + }, + { + "source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens", + "target": "design:2026-08-02-thinking-capability-design", + "relation": "supports", + "evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据", + "added": "2026-08-02T09:38:57.033054+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 7b60264..1a9cb5e 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-01 01:58 UTC +> 自动生成,更新时间:2026-08-02 09:38 UTC -## design (20) +## design (21) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -22,9 +22,10 @@ - [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` +- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` -## finding (11) +## finding (12) - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` @@ -36,6 +37,7 @@ - [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance` - [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` +- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` ## plan (16) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` diff --git a/research-wiki/log.md b/research-wiki/log.md index c71e234..4eda2ad 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -73,3 +73,8 @@ - [2026-07-31 16:59 UTC] 重建索引: 50 篇页面 - [2026-07-31 17:01 UTC] 重建索引: 50 篇页面 - [2026-08-01 01:58 UTC] 重建索引: 50 篇页面 +- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面 +- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design +- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens) +- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design) +- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符 diff --git a/research-wiki/query_pack.md b/research-wiki/query_pack.md index 72b678a..67c65cf 100644 --- a/research-wiki/query_pack.md +++ b/research-wiki/query_pack.md @@ -1,3 +1,3 @@ # Query Pack -> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。 +> 自动生成,请勿手动编辑。 From e5871cccd2d92c39379db05ea1a27aeef4605946 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 05:49:55 -0400 Subject: [PATCH 2/7] docs: add implementation plan for thinking capability and reasoning tokens Ten tasks in a fixed order: land reasoning_tokens first so it can serve as the acceptance instrument for the thinking-switch fix, then reshape the provider profile, add the model-level capability table, wire the assembly guard, fold enable_thinking into the cache fingerprint, and verify the whole thing against the live API. Incorporates a read-only Codex review: resolve_thinking now takes the model name so its errors can name it, and the warning assertion uses a loguru sink because caplog cannot see loguru output. --- research-wiki/graph/edges.json | 7 + research-wiki/index.md | 5 +- research-wiki/log.md | 4 + .../plans/2026-08-02-thinking-capability.md | 274 ++++++++++++++++++ 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 research-wiki/plans/2026-08-02-thinking-capability.md diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index d7b0e18..56449c8 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -230,6 +230,13 @@ "relation": "supports", "evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据", "added": "2026-08-02T09:38:57.033054+00:00" + }, + { + "source": "plan:2026-08-02-thinking-capability", + "target": "design:2026-08-02-thinking-capability-design", + "relation": "implements", + "evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准", + "added": "2026-08-02T09:49:48.126539+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 1a9cb5e..ca16d39 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-02 09:38 UTC +> 自动生成,更新时间:2026-08-02 09:49 UTC ## design (21) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -39,7 +39,7 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (16) +## plan (17) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -55,6 +55,7 @@ - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` +- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## schema (1) diff --git a/research-wiki/log.md b/research-wiki/log.md index 4eda2ad..3d481c2 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -78,3 +78,7 @@ - [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens) - [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design) - [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符 +- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面 +- [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design +- [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability) +- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面 diff --git a/research-wiki/plans/2026-08-02-thinking-capability.md b/research-wiki/plans/2026-08-02-thinking-capability.md new file mode 100644 index 0000000..2394c8e --- /dev/null +++ b/research-wiki/plans/2026-08-02-thinking-capability.md @@ -0,0 +1,274 @@ +--- +type: plan +node_id: plan:2026-08-02-thinking-capability +title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)" +date: 2026-08-02 +--- + +# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6) + +**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。 + +**方案概述**:`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3 的 `_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。 + +**涉及技术**:Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。 + +**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`。 + +**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。 + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` | +| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` | +| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens`;`_build_payload` 接入 `resolve_thinking`;收 `capabilities` | +| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` | +| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 | +| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) | +| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 | +| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 | +| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` | +| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1–L9 | +| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 | + +**任务顺序不可调换**:T1–T3 先把 `reasoning_tokens` 打通(#6 是 #5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。 + +## 关键接口(跨任务消费,此处给出实际代码) + +`providers.py` 新增: + +```python +@dataclass(frozen=True) +class ThinkingCapability: + """某个具体模型的推理能力(model 级);登记必须附实测证据与日期。""" + + can_disable: bool + evidence: str + + +def get_capability( + model: str, *, table: Mapping[str, ThinkingCapability] | None = None +) -> ThinkingCapability | None: + """按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。""" +``` + +```python +def register_capability( + model: str, + capability: ThinkingCapability, + *, + base: Mapping[str, ThinkingCapability] | None = None, +) -> dict[str, ThinkingCapability]: + """纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。""" + + +def resolve_thinking( + profile: ProviderProfile, + capability: ThinkingCapability | None, + enable_thinking: bool | None, + *, + model: str, +) -> Mapping[str, Any]: + """三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。 + + model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性, + 而 capability 为 None(未登记)时无从从别处取得模型名。 + """ +``` + +`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**): + +| 步 | 条件 | 行为 | +|---|---|---| +| 1 | `enable_thinking is None` | 返回 `{}`(不干预) | +| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` | +| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) | +| 4 | `enable_thinking is False` 且 `capability.can_disable is False` | `ValueError`:该模型无法关闭推理 | +| 5 | 其余 | 返回 `slot` | + +第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。 + +`transports/openai_compat.py` 新增: + +```python +def _coerce_reasoning_tokens(usage: Any) -> int | None: + """取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。""" +``` + +## 任务清单 + +### T1 — `reasoning_tokens` 进入类型与采集路径 + +- [ ] **文件**:改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;改测 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py` + +**行为**:`LLMResponse` 与 `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens`(`openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None`;`completion_tokens_details` 非 `dict` 返回 `None`;`bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None`;`0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。 + +**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。 + +**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7` → `reasoning_tokens == 7`;该键为 `0` → `0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None`;`missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`。 + +**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。 + +### T2 — 遥测端口 21 → 22 字段与两后端落库 + +- [ ] **文件**:改 `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;改测 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py` + +**行为**:`record_llm_call` 在 `sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`(sqlite)/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py` 的 `_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt` 用 `response.reasoning_tokens if response else None`,`emit_cache_hit` 原样回放,`emit_terminal_failure` 填 `None`。 + +**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。 + +**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0` 与 `NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。 + +**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。 + +### T3 — 提交点:issue #6 完整可用 + +- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。 + +**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。 + +### T4 — 形态层放宽与 profile 修正 + +- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py` + +**行为**:`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring:`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25` 与 `:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。 + +`minimax` 填 `thinking_on={"reasoning_effort": "medium"}`、`thinking_off={"reasoning_effort": "none"}`;`openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。 + +**验收**:`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}`;`get_provider("openai").thinking_on is None`;qwen / deepseek 两档与改动前逐字相同。 + +**测试证据**:`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。 + +### T5 — 能力层与 `resolve_thinking` + +- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py` + +**行为**:按「关键接口」一节的签名实现 `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider`(`providers.py:84-90`)同形。 + +`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量: + +| 键 | `can_disable` | `evidence` 要点 | +|---|---|---| +| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 | +| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter 登记 `mandatory:true` | +| `MiniMax-M2.5` | `False` | 同上 | +| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 | +| `deepseek-v4-pro` | `True` | 实测 `thinking:{"type":"disabled"}` 关闭 | + +**验收**:`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning(**loguru 不经标准 logging,pytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7` 抛 `ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider` 与 `extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`。 + +**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。 + +### T6 — transport 接入与请求期兜底 + +- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py` + +**行为**:`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities`。`_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)` 与 `payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。 + +**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。 + +**验收**:`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"`;`False` → `"none"`;`None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`。 + +**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。 + +### T7 — 装配守卫、参数贯通与缓存指纹 + +- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py` + +**行为**(三件事,同一文件): + +其一,`from_settings` 与 `from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport`;`from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider` 在 `client.py:248` 与 `openai_compat.py:313` 双点调用的既有形态一致。 + +其二,`build_model_fingerprint`(`client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**: + +```python +def _fingerprint_mark(s: SourceConfig) -> str: + parts: list[Any] = [s.model, dict(s.extra_body)] + if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变 + parts.append(s.enable_thinking) + return json.dumps(parts, sort_keys=True, ensure_ascii=False) +``` + +筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`。 + +其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7` 的 `GatewaySettings` 经 `from_settings` → `ValueError`;`provider=openai` + 任意非 `None` 的 `enable_thinking` → `ValueError`。 + +**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。 + +**测试证据**:先写三条断言 → 失败 → 实现 → 通过。 + +**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。 + +### T8 — 真实 API e2e 矩阵 + +- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py` + +**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**。 + +**源映射**:L1–L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*`(`MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*` 与 `{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。 + +覆盖矩阵(轮数经环境变量可调,默认值如下): + +| # | 场景 | 源 | 轮数 | 判据 | +|---|---|---|---|---| +| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) | +| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` | +| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 | +| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` | +| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2(`stream=True` 是库的默认主路径) | +| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` | +| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` | +| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 | +| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 | + +**三条必须遵守的测试纪律**: + +其一,**主判据用 `completion_tokens`,`reasoning_tokens` 只作辅助**。中转在上游不返回 usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c 实测同一请求 10 轮呈 6:4 双峰),拿它做单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。 + +其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。关掉后 `completion_tokens` 极稳定(实测 4–10),推理量则方差大。 + +其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。 + +**报告内容**(`tests/outputs/e2e/test_thinking_live_.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。 + +**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`。 + +**验证**:`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。 + +### T9 — 文档同步与收尾 + +- [ ] **文件**:改 `CHANGELOG.md`、`research-wiki/schemas/llm-calls.md` + +**行为**:CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。 + +Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API`(`LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。 + +**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。 + +**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。 + +## 完成后的独立验证 + +按 `verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。 + +### T10 — 同步结论给 dissect + +- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。 + +**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。 + +## 需要同步给下游的结论 + +`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。 From 89ff916bc8fd8e2187ef5d0b1ced6d52148315e9 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 05:55:37 -0400 Subject: [PATCH 3/7] feat: collect reasoning_tokens from the provider usage payload (issue #6) Reasoning tokens are already counted inside completion_tokens, so the cost total was never wrong -- what was missing is the attribution: how much of a call was spent thinking rather than answering. LLMResponse and TransportResult each gain a trailing reasoning_tokens field, and the telemetry port grows from 21 to 22 columns with the new column appended in both backends so fresh and migrated schemas keep the same physical order. None means this particular call did not report the field, not that the source never reports it: a relay that falls back to a local tokenizer replaces the whole usage object and drops completion_tokens_details. Downstream checks must therefore read "in (None, 0)"; no provider was observed reporting a literal zero. --- src/polygateway/middleware/retry.py | 1 + src/polygateway/middleware/telemetry.py | 5 ++ src/polygateway/ports.py | 1 + src/polygateway/telemetry/postgres.py | 5 +- src/polygateway/telemetry/sqlite.py | 5 +- src/polygateway/transports/openai_compat.py | 21 ++++++ src/polygateway/types.py | 12 +++- tests/integration/test_postgres_telemetry.py | 2 + tests/unit/test_openai_compat.py | 75 ++++++++++++++++++++ tests/unit/test_ports.py | 1 + tests/unit/test_retry.py | 3 + tests/unit/test_telemetry.py | 30 +++++++- tests/unit/test_types.py | 4 ++ 13 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index acb7da7..60c746b 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -438,6 +438,7 @@ class RetryMW: usage_source=result.usage_source, cached_prompt_tokens=result.cached_prompt_tokens, model_reported=result.model_reported, + reasoning_tokens=result.reasoning_tokens, ) async def _settle_and_release(self, permit: Permit, actual: int) -> None: diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index b2093c7..66b7cfb 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -64,6 +64,7 @@ class TelemetryEmitter: error=error, cached_prompt_tokens=response.cached_prompt_tokens if response else None, model_reported=response.model_reported if response else None, + reasoning_tokens=response.reasoning_tokens if response else None, # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), ) @@ -90,6 +91,7 @@ class TelemetryEmitter: # 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。 cached_prompt_tokens=response.cached_prompt_tokens, model_reported=response.model_reported, + reasoning_tokens=response.reasoning_tokens, # 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损: # sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同 sampling=canonical_sampling_json(request.sampling), @@ -117,6 +119,7 @@ class TelemetryEmitter: error=error, cached_prompt_tokens=None, model_reported=None, + reasoning_tokens=None, # 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D) sampling=canonical_sampling_json(request.sampling), ) @@ -142,6 +145,7 @@ class TelemetryEmitter: cached_prompt_tokens: int | None, model_reported: str | None, sampling: str | None, + reasoning_tokens: int | None, ) -> None: try: # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); @@ -182,6 +186,7 @@ class TelemetryEmitter: cached_prompt_tokens=cached_prompt_tokens, model_reported=model_reported, sampling=sampling, + reasoning_tokens=reasoning_tokens, ) except asyncio.CancelledError: raise diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 5425dfe..1765d85 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -275,4 +275,5 @@ class TelemetryRecorder(Protocol): cached_prompt_tokens: int | None, model_reported: str | None, sampling: str | None, + reasoning_tokens: int | None, ) -> None: ... diff --git a/src/polygateway/telemetry/postgres.py b/src/polygateway/telemetry/postgres.py index f3fac3c..a195aa5 100644 --- a/src/polygateway/telemetry/postgres.py +++ b/src/polygateway/telemetry/postgres.py @@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS llm_calls ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), cached_prompt_tokens INTEGER, model_reported TEXT, - sampling TEXT + sampling TEXT, + reasoning_tokens INTEGER ); """ @@ -51,6 +52,7 @@ _BACKFILL = ( ("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"), ("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"), ("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"), + ("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"), ) # 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析) @@ -81,6 +83,7 @@ _COLUMNS = ( "cached_prompt_tokens", "model_reported", "sampling", + "reasoning_tokens", ) _INSERT = ( diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index a53b7d9..b8622c6 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS llm_calls ( created_at TEXT NOT NULL DEFAULT (datetime('now')), cached_prompt_tokens INTEGER, model_reported TEXT, - sampling TEXT + sampling TEXT, + reasoning_tokens INTEGER ); """ @@ -47,6 +48,7 @@ _BACKFILL_COLUMNS = ( ("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT"), ("sampling", "TEXT"), + ("reasoning_tokens", "INTEGER"), ) _COLUMNS = ( @@ -71,6 +73,7 @@ _COLUMNS = ( "cached_prompt_tokens", "model_reported", "sampling", + "reasoning_tokens", ) _INSERT = ( diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index c8111df..054b494 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -177,6 +177,25 @@ def _coerce_cached_tokens(usage: Any) -> int | None: return cached +def _coerce_reasoning_tokens(usage: Any) -> int | None: + """取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。 + + 与 `_coerce_cached_tokens` 逐条同构(两者是 OpenAI 兼容 usage 里对称的一对): + `0` 如实保留、负数与非整数归 None、`bool` 显式排除。差别只在语义——本字段 + 的 None 是"**本次调用**未上报"而非"该源不上报": 中转在上游不返回 usage 时 + 会本地补算并整体替换 usage 对象,把 details 一并吃掉(findings §4c)。 + """ + if not isinstance(usage, dict): + return None + details = usage.get("completion_tokens_details") + if not isinstance(details, dict): + return None + reasoning = details.get("reasoning_tokens") + if isinstance(reasoning, bool) or not isinstance(reasoning, int) or reasoning < 0: + return None + return reasoning + + def _coerce_model_reported(value: Any) -> str | None: """取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。 @@ -400,6 +419,7 @@ class OpenAICompatTransport: raw={"usage": sink.get("usage")}, cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), model_reported=_coerce_model_reported(sink.get("model")), + reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")), ) def _check_done( @@ -484,6 +504,7 @@ class OpenAICompatTransport: raw={"usage": body.get("usage")}, cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")), model_reported=_coerce_model_reported(body.get("model")), + reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")), ) async def aclose(self) -> None: diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 723131f..7a21924 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -99,6 +99,15 @@ class LLMResponse: model_reported: str | None = None """API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能 分叉——供应商把别名指向新权重时,实验复现必须认这个串。""" + reasoning_tokens: int | None = None + """推理消耗的输出 token 数(含在 `completion_tokens` 内,故不影响成本总额, + 只补归因;issue #6)。 + + `None` = **本次调用**未上报,**不是**"该源不上报"——中转网关在上游不返回 + usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 + `completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈 + 6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`, + 故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。""" @dataclass(frozen=True) @@ -151,9 +160,10 @@ class TransportResult: ttft_ms: float | None max_inter_token_ms: float | None raw: dict[str, Any] - # —— 可观测字段(issue #3;带默认值,非 OpenAI 兼容的 transport 可不填)—— + # —— 可观测字段(issue #3/#6;带默认值,非 OpenAI 兼容的 transport 可不填)—— cached_prompt_tokens: int | None = None model_reported: str | None = None + reasoning_tokens: int | None = None @dataclass(frozen=True) diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 3b1203c..11382ad 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -43,6 +43,7 @@ _EXPECTED_COLUMNS = [ "cached_prompt_tokens", "model_reported", "sampling", + "reasoning_tokens", ] # run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见 @@ -107,6 +108,7 @@ async def _record_minimal( "cached_prompt_tokens": None, "model_reported": None, "sampling": None, + "reasoning_tokens": None, } fields.update(overrides) await recorder.record_llm_call(**fields) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 5bce879..973cc59 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -394,6 +394,81 @@ class TestObservabilityFields: assert set(result.raw) == {"usage"} +class TestReasoningTokens: + """issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。 + + 实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报 + 0;且中转在上游不返回 usage 时会本地补算并吃掉该对象。故 None 的语义是 + "本次调用未上报",不是"该源不上报"(findings §4c)。 + """ + + def _reasoning_usage(self, reasoning): + return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}} + + async def test_stream_reads_reasoning_tokens(self): + def handler(request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7))) + + result = await _complete(_transport_for(handler), _source()) + assert result.reasoning_tokens == 7 + + async def test_non_stream_reads_reasoning_tokens(self): + def handler(request): + return httpx.Response( + 200, + json={ + "choices": [{"message": {"content": "42"}}], + "usage": self._reasoning_usage(7), + }, + ) + + result = await _complete(_transport_for(handler), _source(), stream=False) + assert result.reasoning_tokens == 7 + + async def test_zero_reasoning_tokens_is_a_real_zero(self): + """0(上报了且确实没推理)与 None(本次未上报)必须可区分。""" + + def handler(request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0))) + + result = await _complete(_transport_for(handler), _source()) + assert result.reasoning_tokens == 0 + + async def test_usage_without_details_is_none(self): + def handler(request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE)) + + result = await _complete(_transport_for(handler), _source()) + assert result.reasoning_tokens is None + + @pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}]) + async def test_malformed_reasoning_tokens_degrade_to_none(self, bad): + """`True` 必须排除: Python 里 isinstance(True, int) 为真。""" + + def handler(request): + return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad))) + + result = await _complete(_transport_for(handler), _source()) + assert result.reasoning_tokens is None + + async def test_details_not_a_dict_is_none(self): + def handler(request): + usage = {**_USAGE, "completion_tokens_details": "oops"} + return _sse_stream(_chunk(content="ok"), _chunk(usage=usage)) + + result = await _complete(_transport_for(handler), _source()) + assert result.reasoning_tokens is None + + async def test_salvage_path_records_none_not_zero(self): + """打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。""" + + def handler(request): + return _sse_stream(_chunk(content="ok"), done=False) + + result = await _complete(_transport_for(handler), _source(missing_done="salvage")) + assert result.reasoning_tokens is None + + class TestNonStreamFastPath: async def test_non_stream_parses_message(self): def handler(request): diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 74bb5f9..fe722eb 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -117,6 +117,7 @@ class _DummyRecorder: cached_prompt_tokens, model_reported, sampling, + reasoning_tokens, ) -> None: ... diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index bb54ae9..affce44 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -209,11 +209,13 @@ class TestObservabilityPassthrough: raw={}, cached_prompt_tokens=64, model_reported="MiniMax-Text-01-250321", + reasoning_tokens=7, ) mw, *_ = _harness([_src("a")], [result]) resp = await mw(_REQ) assert resp.cached_prompt_tokens == 64 assert resp.model_reported == "MiniMax-Text-01-250321" + assert resp.reasoning_tokens == 7 # model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段 assert resp.model == "m" @@ -221,6 +223,7 @@ class TestObservabilityPassthrough: mw, *_ = _harness([_src("a")], [_ok()]) resp = await mw(_REQ) assert resp.cached_prompt_tokens is None and resp.model_reported is None + assert resp.reasoning_tokens is None class TestRetryAndFailover: diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index 25128af..5a2df3c 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -1,4 +1,4 @@ -"""遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。""" +"""遥测子系统测试: SQLiteRecorder(22 列)+ TelemetryEmitter 单一 helper + TelemetryMW。""" import asyncio import json @@ -39,6 +39,7 @@ _EXPECTED_COLUMNS = [ "cached_prompt_tokens", "model_reported", "sampling", + "reasoning_tokens", ] @@ -102,6 +103,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides): "cached_prompt_tokens": None, "model_reported": None, "sampling": None, + "reasoning_tokens": None, } fields.update(overrides) await recorder.record_llm_call(**fields) @@ -158,6 +160,22 @@ class TestSQLiteRecorder: assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL assert rows["c-none"] is None + async def test_reasoning_tokens_column_round_trip(self, tmp_path): + """issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。""" + recorder = SQLiteRecorder(tmp_path / "t.db") + await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7) + await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0) + await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None) + recorder.close() + rows = dict( + sqlite3.connect(tmp_path / "t.db") + .execute("SELECT call_id, reasoning_tokens FROM llm_calls") + .fetchall() + ) + assert rows["r-some"] == 7 + assert rows["r-zero"] == 0 # 上报了且确实没推理 + assert rows["r-none"] is None # 本次调用未上报 + async def test_sampling_column_round_trips(self, tmp_path): """issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。""" recorder = SQLiteRecorder(tmp_path / "t.db") @@ -284,6 +302,7 @@ class TestPostgresBackfillDiscipline: "cached_prompt_tokens", "model_reported", "sampling", + "reasoning_tokens", ] def _recorder(self, conn): @@ -384,11 +403,12 @@ class TestEmitterObservabilityFields: source=_source(), call_id="cid-1", latency_ms=42, - response=_resp(cached_prompt_tokens=64, model_reported="m-real"), + response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7), error=None, ) assert rec.rows[0]["cached_prompt_tokens"] == 64 assert rec.rows[0]["model_reported"] == "m-real" + assert rec.rows[0]["reasoning_tokens"] == 7 async def test_failed_attempt_has_no_provider_facts(self): rec = _MemoryRecorder() @@ -402,16 +422,19 @@ class TestEmitterObservabilityFields: ) assert rec.rows[0]["cached_prompt_tokens"] is None assert rec.rows[0]["model_reported"] is None + assert rec.rows[0]["reasoning_tokens"] is None async def test_cache_hit_replays_the_recorded_values(self): """决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。""" rec = _MemoryRecorder() await TelemetryEmitter(rec).emit_cache_hit( - request=_REQ, response=_resp(cached_prompt_tokens=64, model_reported="m-real") + request=_REQ, + response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7), ) row = rec.rows[0] assert row["cache_hit"] is True assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real" + assert row["reasoning_tokens"] == 7 # 与 cached 同口径原样回放 async def test_terminal_failure_records_none(self): rec = _MemoryRecorder() @@ -420,6 +443,7 @@ class TestEmitterObservabilityFields: ) assert rec.rows[0]["cached_prompt_tokens"] is None assert rec.rows[0]["model_reported"] is None + assert rec.rows[0]["reasoning_tokens"] is None class TestEmitterSamplingColumn: diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 1eae3b8..de288c1 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -54,6 +54,7 @@ class TestLLMResponse: resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid") assert resp.cached_prompt_tokens is None assert resp.model_reported is None + assert resp.reasoning_tokens is None # issue #6: 本次调用未上报 filled = LLMResponse( "c", "t", @@ -68,9 +69,11 @@ class TestLLMResponse: "cid", cached_prompt_tokens=0, model_reported="MiniMax-Text-01-250321", + reasoning_tokens=0, ) assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同 assert filled.model_reported == "MiniMax-Text-01-250321" + assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同 def test_frozen(self): resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid") @@ -247,6 +250,7 @@ class TestAuxTypes: assert s.raw["id"] == "x" # issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动) assert s.cached_prompt_tokens is None and s.model_reported is None + assert s.reasoning_tokens is None class TestOcrTypes: From 82f4ec4910687108c02126eff491b86dad9ae0f1 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 06:20:24 -0400 Subject: [PATCH 4/7] feat: model the thinking switch as shape plus capability (issue #5) enable_thinking=False was a no-op for minimax and openai sources: both profiles had empty dicts on each side, so the payload update injected nothing while the caller believed reasoning had been turned off. A downstream project was blocked on exactly this. The root cause is that an empty dict meant two different things -- "no injection needed" and "we do not know how this provider spells it" -- and that a provider-level table cannot express what turned out to be a per-model property. Live testing showed MiniMax-M3 can disable reasoning via reasoning_effort while M2.7 and M2.5 cannot be disabled at all, which two external registries independently confirm. So the shape stays at provider level and a capability table joins it at model level. Unknown, unsupported and no-opinion are now three distinct values, and resolve_thinking is the single place they meet: it raises at assembly time when a model cannot honour the request, warns and injects for unregistered models, and injects silently otherwise. Every registered capability carries the evidence it was derived from. enable_thinking also joins the cache fingerprint, since it now really does change the request body. --- src/polygateway/client.py | 58 +++++-- src/polygateway/providers.py | 170 ++++++++++++++++++-- src/polygateway/transports/openai_compat.py | 34 +++- tests/unit/test_client.py | 27 ++++ tests/unit/test_config.py | 30 ++++ tests/unit/test_openai_compat.py | 59 +++++++ tests/unit/test_providers.py | 101 +++++++++++- 7 files changed, 439 insertions(+), 40 deletions(-) diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 7ce8e67..2520f00 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -25,7 +25,7 @@ from polygateway.middleware.retry import RetryMW from polygateway.middleware.structured import StructuredMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW from polygateway.pricing import PricingTable -from polygateway.providers import get_provider +from polygateway.providers import get_capability, get_provider, resolve_thinking from polygateway.sources import ( AdaptivePacer, HealthAwareSelector, @@ -51,7 +51,7 @@ if TYPE_CHECKING: TelemetryRecorder, Transport, ) - from polygateway.providers import ProviderProfile + from polygateway.providers import ProviderProfile, ThinkingCapability from polygateway.types import ( BackpressurePolicy, RetryPolicy, @@ -61,22 +61,52 @@ if TYPE_CHECKING: _T = TypeVar("_T") +def _guard_thinking( + sources: list[SourceConfig], + profiles: list[ProviderProfile], + capabilities: Mapping[str, ThinkingCapability] | None, +) -> None: + """装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。 + + 与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路 + (CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期 + 就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。 + """ + for source, profile in zip(sources, profiles, strict=True): + resolve_thinking( + profile, + get_capability(source.model, table=capabilities), + source.enable_thinking, + model=source.model, + ) + + +def _fingerprint_mark(source: SourceConfig) -> str: + """单源的指纹标记;`enable_thinking` 仅在**表态时**追加。 + + 只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4 + 时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。 + """ + parts: list[Any] = [source.model, dict(source.extra_body)] + if source.enable_thinking is not None: + parts.append(source.enable_thinking) + return json.dumps(parts, sort_keys=True, ensure_ascii=False) + + def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str: """缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。 配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1 - 后重启仍会读到旧缓存(issue #4 设计决策 C)。全源 `extra_body` 皆空时 - 字面量与历史实现逐字相同,不触发存量缓存冷启动。 + 后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理 + (issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时 + 缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量 + 缓存冷启动。 """ fingerprint = ",".join(sorted({s.model for s in sources})) - # 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些 - # (模型, 解码参数)组合",改源名不该误触全量冷启动 + # 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope + # 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动 marks = sorted( - { - json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False) - for s in sources - if s.extra_body - } + {_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None} ) if marks: digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest() @@ -241,11 +271,13 @@ class GatewayClient: cache: CacheBackend | None = None, telemetry: TelemetryRecorder | None = None, registry: Mapping[str, ProviderProfile] | None = None, + capabilities: Mapping[str, ThinkingCapability] | None = None, rng: Any = random.random, ) -> GatewayClient: """按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。""" sources = list(settings.sources) profiles = [get_provider(s.provider, registry=registry) for s in sources] + _guard_thinking(sources, profiles, capabilities) strategy, escalation = _build_structured(profiles) return cls( scope=settings.scope, @@ -253,7 +285,7 @@ class GatewayClient: selector=_build_selector(settings.selector, rng=rng), limiter=limiter or _build_limiter(settings, sources), breaker=breaker or _build_breaker(settings), - transport=OpenAICompatTransport(registry=registry), + transport=OpenAICompatTransport(registry=registry, capabilities=capabilities), retry=settings.retry, backpressure=settings.backpressure, quota_full=settings.quota_full, @@ -279,6 +311,7 @@ class GatewayClient: cache: CacheBackend | None = None, telemetry: TelemetryRecorder | None = None, registry: Mapping[str, ProviderProfile] | None = None, + capabilities: Mapping[str, ThinkingCapability] | None = None, env: Mapping[str, str] | None = None, ) -> GatewayClient: """从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。""" @@ -289,6 +322,7 @@ class GatewayClient: cache=cache, telemetry=telemetry, registry=registry, + capabilities=capabilities, ) diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index d523c54..32ef72c 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -10,24 +10,37 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any +from loguru import logger + @dataclass(frozen=True) class ProviderProfile: """单个 provider 的能力与差异声明。 thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为 - True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认); - strip_think_tags 声明响应 content 需剥离 ```` 标签(qwen 系); - supports_native_schema 供 D14 阶梯选择原生 response_format 策略。 + True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不 + 注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ```` + 标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。 - 注: 某个 provider 的两档若皆为空字典(如 openai/minimax),说明该 provider - 无已知的推理开关参数——此时 `enable_thinking` 对它**不产生任何效果**, - 而非静默生效。需要下发自定义参数时用 `SourceConfig.extra_body`。 + 两档各有三种取值,**语义互不重叠**(issue #5): + + ========== ========================================================== + ``{...}`` 已知的注入片段 + ``{}`` 已知**无需注入**任何参数即处于该档 + ``None`` **未知**: 本库不知道该 provider 如何表达这一档 + ========== ========================================================== + + `None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False` + 对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。 + 现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。 + + 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否 + 关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。 """ name: str - thinking_on: dict[str, Any] - thinking_off: dict[str, Any] + thinking_on: Mapping[str, Any] | None + thinking_off: Mapping[str, Any] | None strip_think_tags: bool supports_native_schema: bool = False @@ -47,26 +60,151 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( thinking_off={"thinking": {"type": "disabled"}}, strip_think_tags=False, ), - # 两档皆空 ⇒ `enable_thinking` 对本 provider **不产生任何效果**(调用方 - # 以为关掉了实际没关)。真需要控制推理时经 `SourceConfig.extra_body` 下发 + # OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把 + # kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给 + # 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在 + # 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider "openai": ProviderProfile( name="openai", - thinking_on={}, - thinking_off={}, + thinking_on=None, + thinking_off=None, strip_think_tags=False, ), - # OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理。 - # 同上: 两档皆空 ⇒ `enable_thinking` 对 MiniMax 源不产生任何效果 + # 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2), + # **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被 + # 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。 + # "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的 + # thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近 + # "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。 + # 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段) "minimax": ProviderProfile( name="minimax", - thinking_on={}, - thinking_off={}, + thinking_on={"reasoning_effort": "medium"}, + thinking_off={"reasoning_effort": "none"}, strip_think_tags=False, ), } ) +@dataclass(frozen=True) +class ThinkingCapability: + """某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 + + 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, + 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 + 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 + M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 + + `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 + """ + + can_disable: bool + evidence: str + + +DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType( + { + "MiniMax-M3": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变", + ), + "MiniMax-M2.7": ThinkingCapability( + can_disable=False, + evidence=( + "2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " + "各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段" + ), + ), + "MiniMax-M2.5": ThinkingCapability( + can_disable=False, + evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", + ), + "qwen3.7-plus": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)", + ), + "deepseek-v4-pro": ThinkingCapability( + can_disable=True, + evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)", + ), + } +) +"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。""" + + +def get_capability( + model: str, *, table: Mapping[str, ThinkingCapability] | None = None +) -> ThinkingCapability | None: + """按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。 + + 与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值, + 写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。 + """ + return (DEFAULT_CAPABILITIES if table is None else table).get(model) + + +def register_capability( + model: str, + capability: ThinkingCapability, + *, + base: Mapping[str, ThinkingCapability] | None = None, +) -> dict[str, ThinkingCapability]: + """纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。""" + table = dict(DEFAULT_CAPABILITIES if base is None else base) + table[model] = capability + return table + + +def resolve_thinking( + profile: ProviderProfile, + capability: ThinkingCapability | None, + enable_thinking: bool | None, + *, + model: str, +) -> Mapping[str, Any]: + """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 + + 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 + `RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 + 无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有 + `can_disable` 可读,故 Phase 3 必须先于 Phase 4。 + + `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 + `capability` 为 None(未登记)时无从从别处取得模型名。 + """ + # Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 + if enable_thinking is None: + return {} + slot = profile.thinking_on if enable_thinking else profile.thinking_off + direction = "thinking_on" if enable_thinking else "thinking_off" + # Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 + if slot is None: + raise ValueError( + f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " + f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," + f"或改用 SourceConfig.extra_body 直接下发供应商参数" + ) + # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 + if capability is None: + logger.warning( + "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" + "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", + model, + profile.name, + dict(slot), + ) + return slot + # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 + if enable_thinking is False and not capability.can_disable: + raise ValueError( + f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " + f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" + f"若实验需要关闭思维链,请换用支持关闭的模型" + ) + return slot + + def get_provider( name: str, *, registry: Mapping[str, ProviderProfile] | None = None ) -> ProviderProfile: diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 054b494..7bc1f48 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -21,7 +21,13 @@ from polygateway.errors import ( SourceDeadError, TransientError, ) -from polygateway.providers import ProviderProfile, get_provider +from polygateway.providers import ( + ProviderProfile, + ThinkingCapability, + get_capability, + get_provider, + resolve_thinking, +) from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult @@ -284,9 +290,11 @@ class OpenAICompatTransport: self, *, registry: Mapping[str, ProviderProfile] | None = None, + capabilities: Mapping[str, ThinkingCapability] | None = None, client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None, ) -> None: self._registry = registry + self._capabilities = capabilities self._client_factory = client_factory or _default_client_factory self._clients: dict[str, httpx.AsyncClient] = {} @@ -309,10 +317,12 @@ class OpenAICompatTransport: payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream} if stream: payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款) - if source.enable_thinking is True: - payload.update(profile.thinking_on) - elif source.enable_thinking is False: - payload.update(profile.thinking_off) + # 形态(provider 级)与能力(model 级)在此相遇;不可满足时 ValueError, + # 由 complete() 翻译为四分类之一(issue #5) + capability = get_capability(source.model, table=self._capabilities) + payload.update( + resolve_thinking(profile, capability, source.enable_thinking, model=source.model) + ) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 payload.update(source.extra_body) @@ -330,9 +340,17 @@ class OpenAICompatTransport: ) -> TransportResult: """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。""" profile = get_provider(source.provider, registry=self._registry) - payload = self._build_payload( - messages=messages, source=source, profile=profile, stream=stream, overlay=overlay - ) + try: + payload = self._build_payload( + messages=messages, source=source, profile=profile, stream=stream, overlay=overlay + ) + except ValueError as exc: + # 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它 + raise RequestRejectedError( + f"{source.name} 推理开关无法满足: {exc}", + source_name=source.name, + operation="chat", + ) from exc url = source.base_url.rstrip("/") + "/chat/completions" client = self._client_for(source) ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 637c668..c34a244 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -202,6 +202,33 @@ class TestModelFingerprint: assert plain != tuned assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认 + def test_enable_thinking_changes_fingerprint(self): + """issue #5 配套: thinking 一旦真正改变请求体,就必须进缓存身份。 + + 否则"关掉推理后重启"会读到开着推理时缓存的旧响应——issue #4 为 + temperature 写过逐字相同的理由。 + """ + from polygateway.client import build_model_fingerprint + + plain = build_model_fingerprint([_source()]) + off = build_model_fingerprint([_source(enable_thinking=False)]) + on = build_model_fingerprint([_source(enable_thinking=True)]) + assert len({plain, off, on}) == 3 + + def test_extra_body_only_fingerprint_is_byte_identical_to_before(self): + """只配 extra_body、不表态 thinking 的存量源不得触发冷启动。 + + 字面量在此硬编码: 这条断言的价值全在"逐字相同",改实现时必须先看见它红。 + """ + import hashlib + import json + + from polygateway.client import build_model_fingerprint + + mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False) + expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest() + assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected + def test_source_rename_does_not_change_fingerprint(self): """指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。""" from polygateway.client import build_model_fingerprint diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 579af0c..0532dfc 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -460,6 +460,36 @@ class TestCrossFieldInvariants: with pytest.raises(ValueError, match="lease_ttl_s"): GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0)) + # —— 推理开关的装配守卫(issue #5)—— + + def _thinking_sources(self, provider, model, enable_thinking): + base = self._base() + src = dataclasses.replace( + base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking + ) + return dataclasses.replace(base, sources=(src,)) + + def test_model_that_cannot_disable_thinking_fails_at_assembly(self): + """M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。""" + settings = self._thinking_sources("minimax", "MiniMax-M2.7", False) + with pytest.raises(ValueError, match="MiniMax-M2.7"): + GatewayClient.from_settings(settings) + + def test_unknown_thinking_shape_fails_at_assembly(self): + """provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。""" + settings = self._thinking_sources("openai", "kimi-k3", False) + with pytest.raises(ValueError, match="register_provider"): + GatewayClient.from_settings(settings) + + def test_supported_combination_assembles(self): + settings = self._thinking_sources("minimax", "MiniMax-M3", False) + assert GatewayClient.from_settings(settings) is not None + + def test_not_taking_a_position_never_trips_the_guard(self): + """enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。""" + settings = self._thinking_sources("openai", "kimi-k3", None) + assert GatewayClient.from_settings(settings) is not None + def test_ocr_settings_cannot_wrap_invalid_gateway(self): """OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。""" base = self._base() diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 973cc59..2e85b56 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -506,6 +506,65 @@ class TestRequestShaping: assert "enable_thinking" not in seen assert seen["stream_options"] == {"include_usage": True} + @pytest.mark.parametrize( + ("enable_thinking", "expected"), + [(True, "medium"), (False, "none")], + ) + async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected): + """issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。""" + seen = {} + + def handler(request): + seen.update(json.loads(request.content)) + return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) + + source = _source( + name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking + ) + await _complete(_transport_for(handler), source) + assert seen["reasoning_effort"] == expected + assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发 + + async def test_extra_body_overrides_the_profile_slot(self): + """注入顺序即优先级: profile → extra_body → overlay,两行不可调换。""" + seen = {} + + def handler(request): + seen.update(json.loads(request.content)) + return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) + + source = _source( + name="mm", + provider="minimax", + model="MiniMax-M3", + enable_thinking=True, + extra_body={"reasoning_effort": "high"}, + ) + await _complete(_transport_for(handler), source) + assert seen["reasoning_effort"] == "high" + + async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self): + """M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。 + + 裸异常会逃出 chat() —— 它不属错误四分类、TelemetryMW 也不捕,结果是一行 + 遥测都没有就崩了(设计 §5.1)。 + """ + + def handler(request): # pragma: no cover - 不该走到发请求 + raise AssertionError("请求不该发出") + + source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False) + with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"): + await _complete(_transport_for(handler), source) + + async def test_unknown_shape_is_rejected(self): + def handler(request): # pragma: no cover - 不该走到发请求 + raise AssertionError("请求不该发出") + + source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False) + with pytest.raises(RequestRejectedError, match="register_provider"): + await _complete(_transport_for(handler), source) + async def test_overlay_merged_into_payload(self): seen = {} diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index 6d4460e..f3fa8b4 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -1,12 +1,18 @@ """providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。""" import pytest +from loguru import logger from polygateway.providers import ( + DEFAULT_CAPABILITIES, DEFAULT_PROFILES, ProviderProfile, + ThinkingCapability, + get_capability, get_provider, + register_capability, register_provider, + resolve_thinking, ) @@ -24,14 +30,21 @@ class TestDefaultProfiles: assert p.thinking_off == {"thinking": {"type": "disabled"}} assert p.strip_think_tags is False - def test_openai_baseline_profile(self): + def test_openai_slots_are_unknown_not_empty(self): + """issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此), + + 故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错, + 而不是空字典那种"注入了个寂寞"的静默失效。 + """ p = get_provider("openai") - assert p.thinking_on == {} and p.thinking_off == {} + assert p.thinking_on is None and p.thinking_off is None assert p.strip_think_tags is False - def test_minimax_baseline_profile(self): + def test_minimax_profile_uses_reasoning_effort(self): + """2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。""" p = get_provider("minimax") - assert p.thinking_on == {} and p.thinking_off == {} + assert p.thinking_off == {"reasoning_effort": "none"} + assert p.thinking_on == {"reasoning_effort": "medium"} assert p.strip_think_tags is False def test_unknown_provider_fails_loudly(self): @@ -62,3 +75,83 @@ class TestPureFunctionRegistration: def test_default_profiles_mapping_is_read_only(self): with pytest.raises(TypeError): DEFAULT_PROFILES["hack"] = None # type: ignore[index] + + +def _warnings(): + """捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。""" + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + return messages, sink_id + + +class TestThinkingCapability: + """issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。""" + + def test_registered_models_carry_evidence(self): + """登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。""" + for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"): + cap = get_capability(model) + assert cap is not None and cap.evidence.strip() + + def test_m3_can_disable_but_m2x_cannot(self): + assert get_capability("MiniMax-M3").can_disable is True + assert get_capability("MiniMax-M2.7").can_disable is False + assert get_capability("MiniMax-M2.5").can_disable is False + + def test_unregistered_model_is_unknown(self): + assert get_capability("some-brand-new-model") is None + + def test_register_capability_is_pure(self): + table = register_capability("x-1", ThinkingCapability(True, "实测")) + assert get_capability("x-1", table=table) is not None + assert get_capability("x-1") is None # 默认表未被污染 + + def test_default_capabilities_mapping_is_read_only(self): + with pytest.raises(TypeError): + DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index] + + +class TestResolveThinking: + """五条判定规则(顺序即语义);设计 §5 真值表。""" + + def test_rule1_none_injects_nothing(self): + got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3") + assert got == {} + + @pytest.mark.parametrize("enable", [True, False]) + def test_rule2_unknown_shape_raises_and_points_the_way(self, enable): + with pytest.raises(ValueError, match="register_provider") as exc: + resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3") + assert "extra_body" in str(exc.value) + + def test_rule3_unregistered_model_warns_but_passes(self): + messages, sink_id = _warnings() + try: + got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9") + finally: + logger.remove(sink_id) + assert got == {"reasoning_effort": "none"} + assert any("MiniMax-M9" in m for m in messages) + + def test_rule4_cannot_disable_raises_with_the_model_name(self): + cap = get_capability("MiniMax-M2.7") + with pytest.raises(ValueError, match="MiniMax-M2.7"): + resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7") + + def test_rule4_only_blocks_the_off_direction(self): + """关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。""" + cap = get_capability("MiniMax-M2.7") + got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7") + assert got == {"reasoning_effort": "medium"} + + def test_rule5_normal_path(self): + cap = get_capability("MiniMax-M3") + assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == { + "reasoning_effort": "none" + } + + def test_unknown_shape_beats_capability_check(self): + """第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。""" + cap = ThinkingCapability(can_disable=False, evidence="构造") + with pytest.raises(ValueError, match="register_provider"): + resolve_thinking(get_provider("openai"), cap, False, model="whatever") From 4c135075b35975c642ee909d642017f8576c6e42 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 06:55:38 -0400 Subject: [PATCH 5/7] test: verify the thinking switch against the live API (issue #5, #6) A sixteen-row matrix over 127 real calls: disable and enable on MiniMax-M3 in both streaming and non-streaming mode, extra_body winning over the profile slot, qwen and deepseek still disabling correctly, a drift sentinel that re-derives every registered capability from live behaviour, and the assembly guard refusing the models that cannot comply. Two judgement criteria had to be corrected by the data they were meant to judge. Output length cannot separate the two regimes at all -- the disabled runs reach 46 tokens when the model narrates its working in the visible answer, and the enabled runs drop to 13 when medium effort barely thinks. reasoning_tokens separates them cleanly in both directions, which is precisely what issue #6 was collected for. A second anchor compares prompt_tokens between the two regimes: the vendor injects a reasoning instruction when thinking is on, so the input side grows, and comparing the two runs relatively avoids hardcoding any vendor number. Provider names are mapped explicitly rather than guessed from the model string; guessing had silently skipped the qwen row behind a "source unavailable" reason that was not true. --- CHANGELOG.md | 26 ++ ...02-thinking-switch-and-reasoning-tokens.md | 18 +- research-wiki/index.md | 4 +- research-wiki/log.md | 2 + research-wiki/schemas/llm-calls.md | 7 +- tests/e2e/test_thinking_live.py | 385 ++++++++++++++++++ 6 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/test_thinking_live.py diff --git a/CHANGELOG.md b/CHANGELOG.md index baa7f95..175a6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 未发布(issue #5 + #6) + +推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。 + +### 行为变更(**请先读这一条**) + +- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none`、`True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。 +- **`MiniMax-M2.7` / `MiniMax-M2.5` 配 `ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。 +- **`provider=openai` 的源配任何非 `None` 的 `ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。 +- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。 + +### 新增 + +- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。 +- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。 +- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。 +- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。 + +### 下游请读 + +- **`reasoning_tokens` 的 `None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。 +- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`。 +- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。 +- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。 +- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。 + ## 1.0.5(2026-07-31) 采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。 diff --git a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md index 594350d..b281a81 100644 --- a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md +++ b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md @@ -18,7 +18,7 @@ date: 2026-08-02 | 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) | | 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 | | 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" | -| 判据 | 首选 `usage.completion_tokens_details.reasoning_tokens`;该字段缺失时以 `completion_tokens` 兜底(关闭推理应 <30,推理中 >150) | +| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) | | 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 | **方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。 @@ -71,6 +71,21 @@ date: 2026-08-02 **结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。 +### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮) + +初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪: + +| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` | +|---|---|---| +| 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` | +| 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 | + +**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。 + +而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。 + +另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。 + ### 2.4 M3 的稳定性 同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。 @@ -177,3 +192,4 @@ LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temp 4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。 5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。 6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。 +7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。 diff --git a/research-wiki/index.md b/research-wiki/index.md index ca16d39..3bbb1e8 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-02 09:49 UTC +> 自动生成,更新时间:2026-08-02 10:55 UTC ## design (21) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -59,7 +59,7 @@ - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` ## schema (1) -- [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls` +- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` diff --git a/research-wiki/log.md b/research-wiki/log.md index 3d481c2..0618779 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -82,3 +82,5 @@ - [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design - [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability) - [2026-08-02 09:49 UTC] 重建索引: 53 篇页面 +- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面 +- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测) diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index 236478e..9c3ee5d 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -1,11 +1,11 @@ --- type: schema node_id: schema:llm-calls -title: "表结构: llm_calls(遥测 21 字段)" +title: "表结构: llm_calls(遥测 22 字段)" date: 2026-07-20 --- -# 表结构: llm_calls(遥测 21 字段) +# 表结构: llm_calls(遥测 22 字段) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) @@ -27,6 +27,7 @@ date: 2026-07-20 | cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | | sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 | +| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 | ## usage/成本口径(2026-07-30,est_tokens 解耦) @@ -53,6 +54,8 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL; ## 采样参数口径(2026-07-31,issue #4) +`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。 + `sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。 三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同: diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py new file mode 100644 index 0000000..9e2eb33 --- /dev/null +++ b/tests/e2e/test_thinking_live.py @@ -0,0 +1,385 @@ +"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。 + +本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径, +验证不了"这个参数在这个模型上到底关没关掉推理"。 + +两条判据纪律(来自 findings §4c 的实测教训): + +1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出 + 长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文), + 开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。 + 而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None, + 开启 15/15 大于 0。 +2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的 + `prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧 + token 数随之变大。这是相对比较,不硬编码任何具体数值。 +3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回 + usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c), + 开启方向因此可能偶尔观测不到;关闭方向不受影响。 + +源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 +""" + +import dataclasses +import json +import os +from collections import Counter +from datetime import datetime +from pathlib import Path + +import pytest +from dotenv import dotenv_values + +from polygateway import GatewayClient, GatewaySettings +from polygateway.errors import RequestRejectedError +from polygateway.providers import DEFAULT_CAPABILITIES, get_capability + +_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} +_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) + +pytestmark = pytest.mark.skipif( + not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)" +) + +_OUT_DIR = Path("tests/outputs/e2e") +_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10")) + +# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数, +# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里 +_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。" + +_ON_MIN_COMPLETION = 100 +"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。""" + +_ROWS: list[dict] = [] + +# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。 +# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是 +# 在 L8 里被"源不可用"这个假理由吞掉 +_MODEL_PROVIDER = { + "MiniMax-M3": "minimax", + "MiniMax-M2.7": "minimax", + "MiniMax-M2.5": "minimax", + "qwen3.7-plus": "qwen", + "deepseek-v4-pro": "deepseek", +} + + +def _base_settings() -> GatewaySettings: + # 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次 + # 变成对第一轮的回放,整组判据随之失效 + return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"}) + + +def _settings(**source_overrides) -> GatewaySettings: + base = _base_settings() + source = dataclasses.replace(base.sources[0], **source_overrides) + return dataclasses.replace(base, sources=(source,)) + + +async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]: + """跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。""" + client = GatewayClient.from_settings(_settings(**source_overrides)) + observations = [] + try: + for i in range(rounds): + resp = await client.chat( + [{"role": "user", "content": _PROMPT}], + stream=stream, + # 每轮独立 salt: 即便某层缓存意外开着也不会回放 + cache_salt=f"thinking-live-{i}", + ) + observations.append( + { + "round": i + 1, + "prompt_tokens": resp.prompt_tokens, + "completion_tokens": resp.completion_tokens, + "reasoning_tokens": resp.reasoning_tokens, + "content": resp.content[:60], + } + ) + finally: + await client.aclose() + return observations + + +def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None: + _ROWS.append( + { + "matrix": matrix_id, + "desc": desc, + "status": status, + "detail": detail, + "observations": observations or [], + } + ) + + +def _reasoning_off(obs: dict) -> bool: + """关闭方向: 只看 reasoning_tokens。 + + **刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做 + "只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会 + 把这种正常波动误判成"没关掉"。 + """ + return obs["reasoning_tokens"] in (None, 0) + + +def _reasoning_on(obs: dict) -> bool: + """开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。 + + 不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大 + (实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。 + 仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。 + """ + reasoning = obs["reasoning_tokens"] + if reasoning is not None: + return reasoning > 0 + return obs["completion_tokens"] > _ON_MIN_COMPLETION + + +def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str): + """源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。""" + _record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200]) + pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}") + + +@pytest.fixture(scope="module", autouse=True) +def _write_report(): + yield + _OUT_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = _OUT_DIR / f"test_thinking_live_{ts}.md" + lines = [ + "# 推理开关与 reasoning_tokens 真实 API 验证", + "", + f"- 时间: {ts}", + f"- 每档轮数: {_ROUNDS}", + "- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限" + "(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)", + f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})", + "- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数", + "", + "## 矩阵结论", + "", + "| 矩阵 | 场景 | 结论 | 说明 |", + "|---|---|---|---|", + ] + total_calls = 0 + for row in _ROWS: + detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160] + lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |") + total_calls += len(row["observations"]) + lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""] + for row in _ROWS: + if not row["observations"]: + continue + lines += [f"### {row['matrix']} — {row['desc']}", "", "```json"] + lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2)) + lines += ["```", ""] + uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")] + if uncovered: + lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""] + path.write_text("\n".join(lines), encoding="utf-8") + print(f"\n[e2e 报告] {path}") + + +class TestMiniMaxM3: + """M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。""" + + async def test_l1_disable_actually_disables(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False) + offs = [o for o in obs if _reasoning_off(o)] + _record( + "L1", + "enable_thinking=False(流式)", + "PASS" if len(offs) == len(obs) else "FAIL", + f"{len(offs)}/{len(obs)} 轮确认未推理", + obs, + ) + assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}" + + async def test_l2_enable_actually_enables(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True) + ons = [o for o in obs if _reasoning_on(o)] + _record( + "L2", + "enable_thinking=True(流式,注入 medium)", + "PASS" if len(ons) * 2 > len(obs) else "FAIL", + f"{len(ons)}/{len(obs)} 轮观察到推理", + obs, + ) + assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}" + + async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self): + """确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。 + + 供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是 + 本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值, + 供应商改模板也不会让它假红。 + """ + rounds = max(3, _ROUNDS // 3) + off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False) + on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True) + off_max = max(o["prompt_tokens"] for o in off) + on_min = min(o["prompt_tokens"] for o in on) + _record( + "L2b", + "关闭/开启的 prompt_tokens 可分", + "PASS" if off_max < on_min else "FAIL", + f"关闭档最大 {off_max} < 开启档最小 {on_min}", + off + on, + ) + assert off_max < on_min, ( + f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型" + ) + + async def test_l3_no_opinion_is_the_model_default(self): + obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None) + _record("L3", "enable_thinking=None(不干预,基线)", "PASS", "仅记录基线,不断言方向", obs) + assert len(obs) == _ROUNDS + + async def test_l4_extra_body_overrides_the_profile(self): + """profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。 + + 判据是行为而非报文: 若 extra_body 没赢,拿到的就是 none 的结果(不推理)。 + """ + rounds = max(3, _ROUNDS // 2) + obs = await _run_rounds( + rounds, + model="MiniMax-M3", + enable_thinking=False, + extra_body={"reasoning_effort": "high"}, + ) + ons = [o for o in obs if _reasoning_on(o)] + _record( + "L4", + "extra_body 覆盖 profile 注入", + "PASS" if len(ons) * 2 > len(obs) else "FAIL", + f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)", + obs, + ) + assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}" + + async def test_l5_non_stream_path_matches_stream(self): + """非流式快路径独立于流式实现,采集与注入都要各自验一遍。""" + rounds = max(3, _ROUNDS // 2) + off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False) + on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True) + offs = [o for o in off if _reasoning_off(o)] + ons = [o for o in on if _reasoning_on(o)] + ok = len(offs) == len(off) and len(ons) * 2 > len(on) + _record( + "L5", + "非流式路径重跑 L1/L2", + "PASS" if ok else "FAIL", + f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮", + off + on, + ) + assert len(offs) == len(off), f"非流式关闭方向未满足: {off}" + assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}" + + +class TestOtherProviders: + """qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。""" + + @pytest.mark.parametrize( + ("matrix", "provider", "model"), + [("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")], + ) + async def test_existing_profiles_still_disable(self, matrix, provider, model): + desc = f"{provider} enable_thinking=False" + try: + obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False) + except Exception as exc: # 渠道未开通/下线: 记为未覆盖 + _skip_if_unreachable(exc, matrix, desc) + offs = [o for o in obs if _reasoning_off(o)] + _record( + matrix, + desc, + "PASS" if len(offs) == len(obs) else "FAIL", + f"{len(offs)}/{len(obs)} 轮确认未推理", + obs, + ) + assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}" + + +class TestCapabilityDrift: + """L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。""" + + def test_every_capability_has_a_provider_mapping(self): + """能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。""" + missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER)) + assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}" + + @pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES)) + async def test_declared_capability_matches_reality(self, model): + cap = get_capability(model) + provider = _MODEL_PROVIDER[model] + rounds = max(3, _ROUNDS // 2) + desc = f"{model} 声明 can_disable={cap.can_disable}" + if not cap.can_disable: + # 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用) + with pytest.raises(ValueError, match=model): + GatewayClient.from_settings( + _settings(provider=provider, model=model, enable_thinking=False) + ) + _record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致") + return + try: + obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False) + except Exception as exc: + _skip_if_unreachable(exc, "L8", desc) + offs = [o for o in obs if _reasoning_off(o)] + verdict = Counter(_reasoning_off(o) for o in obs) + _record( + "L8", + desc, + "PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)", + f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭", + obs, + ) + assert len(offs) == len(obs), ( + f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES" + ) + + +class TestAssemblyGuardAgainstRealConfig: + """L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。""" + + def test_l9_m27_rejected_at_assembly(self): + with pytest.raises(ValueError, match="MiniMax-M2.7"): + GatewayClient.from_settings( + _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) + ) + _record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求") + + def test_l9_unknown_shape_rejected_at_assembly(self): + with pytest.raises(ValueError, match="register_provider"): + GatewayClient.from_settings( + _settings(provider="openai", model="kimi-k3", enable_thinking=False) + ) + _record("L9", "provider=openai 形态未知", "PASS", "装配期报错并指路") + + async def test_transport_layer_rejects_when_guard_is_bypassed(self): + """构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。""" + settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False) + client = GatewayClient.from_settings( + dataclasses.replace( + settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),) + ) + ) + try: + # 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport + bad = dataclasses.replace(settings.sources[0], enable_thinking=False) + with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"): + await client._terminal._transport.complete( + messages=[{"role": "user", "content": _PROMPT}], + source=bad, + stream=True, + overlay={}, + call_id="e2e-guard", + ) + finally: + await client.aclose() + _record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类") From 48805cb9fb76291e227475badb72691f14829177 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 07:40:06 -0400 Subject: [PATCH 6/7] fix: address the independent verification findings (issue #5, #6) The verifier caught that the disable-direction evidence only proved "no regression", not "actually took effect": on M3 the disabled runs and the no-opinion baseline are identically distributed, because that model does not reason by default anyway. So the disable runs alone cannot rule out the very failure mode issue #5 is about -- the parameter being silently dropped upstream. The bogus-value experiment that does rule it out was sitting in the findings document instead of the test suite; it is now case L3b, and the L3 assertion that could never fail is gone. Also from the review: the e2e helper caught bare Exception, which would have disguised a library bug as an unavailable source, exactly the silence the reporting discipline exists to prevent; the unregistered model warning fired on every request instead of once per source; and the transport caught ValueError broadly enough to mislabel unrelated errors, now narrowed to a dedicated ThinkingUnsupportedError. The design and plan still described the original judgement criteria, which the measurements had already overturned. Both now match what the tests actually do, and the design no longer claims the only new failure surface is the openai one -- dissect configures MiniMax-M2.7 with ENABLE_THINKING=false and will fail at assembly, which has to be coordinated before this merges. --- .../2026-08-02-thinking-capability-design.md | 6 +- ...02-thinking-switch-and-reasoning-tokens.md | 8 +-- .../plans/2026-08-02-thinking-capability.md | 6 +- src/polygateway/providers.py | 40 +++++++++--- src/polygateway/transports/openai_compat.py | 19 +++++- tests/e2e/test_thinking_live.py | 62 +++++++++++++++++-- tests/unit/test_openai_compat.py | 40 ++++++++++++ 7 files changed, 155 insertions(+), 26 deletions(-) diff --git a/research-wiki/designs/2026-08-02-thinking-capability-design.md b/research-wiki/designs/2026-08-02-thinking-capability-design.md index 8050ac4..ebe6f93 100644 --- a/research-wiki/designs/2026-08-02-thinking-capability-design.md +++ b/research-wiki/designs/2026-08-02-thinking-capability-design.md @@ -209,7 +209,7 @@ e2e 不进 CI 自动门的理由是外部不可用会误伤:实测中 kimi 渠 ### 9.3 三条必须遵守的测试纪律 -**(a)主判据选不会被中转污染的量。** `reasoning_tokens` 会被 new-api 的本地补算吃掉(实测 6:4 随机),单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。因此**"是否关闭"的主判据用 `completion_tokens` 阈值,`reasoning_tokens` 作辅助**。这是本次实测最重要的工程教训之一。 +**(a)判别量只能是 `reasoning_tokens`。**(2026-08-02 e2e 实测修正:本节初稿写的是"主判据用 `completion_tokens`",被数据推翻。)两档的输出长度分布**重叠**——关闭档实测最高 46(模型偶尔把解题过程写进正文),开启档最低 13(medium 档想得少的轮次),按长度阈值判两个方向都会误判;而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点:关闭档 `prompt_tokens` 严格小于开启档(实测 194 < 207)。 **(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。 @@ -249,7 +249,9 @@ e2e 不进 CI 自动门的理由是外部不可用会误伤:实测中 kimi 渠 **能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。 -**三个下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。新增的失败面仅有 `provider=openai` + 配了 `ENABLE_THINKING` 这一组合,经全仓与 dissect 检索当前无此用法。 +**新增两处失败面,其中一处会立刻打挂 dissect。**(2026-08-02 独立核验修正:本节初稿只列了 `openai` 那一处,遗漏了 M2.x。)其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false` —— 合并后该 scope 装配即抛 `ValueError`,实验链启动就挂。这是本设计的**预期行为**(给不了语义保证就必须说),但必须与 dissect 协调后再合并,不能突然打挂它。 + +**三个参考下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。 ## 13. 另立 issue(不在本次范围) diff --git a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md index b281a81..9c9ee30 100644 --- a/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md +++ b/research-wiki/findings/2026-08-02-thinking-switch-and-reasoning-tokens.md @@ -71,6 +71,10 @@ date: 2026-08-02 **结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。 +### 2.4 M3 的稳定性 + +同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。 + ### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮) 初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪: @@ -86,10 +90,6 @@ date: 2026-08-02 另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。 -### 2.4 M3 的稳定性 - -同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。 - ## 3. qwen / deepseek:现有 profile 正确 | 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile | diff --git a/research-wiki/plans/2026-08-02-thinking-capability.md b/research-wiki/plans/2026-08-02-thinking-capability.md index 2394c8e..43ac10b 100644 --- a/research-wiki/plans/2026-08-02-thinking-capability.md +++ b/research-wiki/plans/2026-08-02-thinking-capability.md @@ -235,9 +235,11 @@ def _fingerprint_mark(s: SourceConfig) -> str: **三条必须遵守的测试纪律**: -其一,**主判据用 `completion_tokens`,`reasoning_tokens` 只作辅助**。中转在上游不返回 usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c 实测同一请求 10 轮呈 6:4 双峰),拿它做单轮断言必然 flaky;而 `completion_tokens` 在补算路径下依然有值。 +其一,**判别量只能是 `reasoning_tokens`**。(执行时按 e2e 实测修正:本条初稿写的是「主判据用 `completion_tokens`」,被数据推翻——两档的输出长度分布**重叠**,关闭档实测最高 46、开启档最低 13,按长度阈值判两个方向都会误判。)`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路(findings §4c、§2.5)。 -其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。关掉后 `completion_tokens` 极稳定(实测 4–10),推理量则方差大。 +其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到,关闭方向不受影响。 + +其四,**必须有不依赖输出侧噪声的锚点**:L2b 比较两档的 `prompt_tokens`(相对比较,无魔数),L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态,不排除它,关闭方向的证据就只到「未回归」,够不到「已生效」。 其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。 diff --git a/src/polygateway/providers.py b/src/polygateway/providers.py index 32ef72c..e64d4b4 100644 --- a/src/polygateway/providers.py +++ b/src/polygateway/providers.py @@ -87,6 +87,17 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType( ) +class ThinkingUnsupportedError(ValueError): + """推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。 + + 是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置** + 不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期 + 捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让 + 捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关 + 错误误贴成"推理开关无法满足")。 + """ + + @dataclass(frozen=True) class ThinkingCapability: """某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 @@ -162,6 +173,7 @@ def resolve_thinking( enable_thinking: bool | None, *, model: str, + warn_unregistered: bool = True, ) -> Mapping[str, Any]: """三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。 @@ -172,6 +184,9 @@ def resolve_thinking( `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 `capability` 为 None(未登记)时无从从别处取得模型名。 + + `warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 + 调用再喊只会刷屏。判定结果不受此参数影响。 """ # Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 if enable_thinking is None: @@ -180,31 +195,36 @@ def resolve_thinking( direction = "thinking_on" if enable_thinking else "thinking_off" # Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 if slot is None: - raise ValueError( + raise ThinkingUnsupportedError( f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): " f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," f"或改用 SourceConfig.extra_body 直接下发供应商参数" ) # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 if capability is None: - logger.warning( - "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" - "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", - model, - profile.name, - dict(slot), - ) + if warn_unregistered: + _warn_unregistered(model, profile, slot) return slot # Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 if enable_thinking is False and not capability.can_disable: - raise ValueError( + raise ThinkingUnsupportedError( f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——" - f"若实验需要关闭思维链,请换用支持关闭的模型" + f"需要关闭思维链请换用支持关闭的模型" ) return slot +def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None: + logger.warning( + "模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" + "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记", + model, + profile.name, + dict(slot), + ) + + def get_provider( name: str, *, registry: Mapping[str, ProviderProfile] | None = None ) -> ProviderProfile: diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 7bc1f48..2a48a82 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -24,6 +24,7 @@ from polygateway.errors import ( from polygateway.providers import ( ProviderProfile, ThinkingCapability, + ThinkingUnsupportedError, get_capability, get_provider, resolve_thinking, @@ -295,6 +296,9 @@ class OpenAICompatTransport: ) -> None: self._registry = registry self._capabilities = capabilities + # 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。 + # 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律 + self._warned_models: set[str] = set() self._client_factory = client_factory or _default_client_factory self._clients: dict[str, httpx.AsyncClient] = {} @@ -320,8 +324,16 @@ class OpenAICompatTransport: # 形态(provider 级)与能力(model 级)在此相遇;不可满足时 ValueError, # 由 complete() 翻译为四分类之一(issue #5) capability = get_capability(source.model, table=self._capabilities) + first_time = source.model not in self._warned_models + self._warned_models.add(source.model) payload.update( - resolve_thinking(profile, capability, source.enable_thinking, model=source.model) + resolve_thinking( + profile, + capability, + source.enable_thinking, + model=source.model, + warn_unregistered=first_time, + ) ) # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级 # overlay(含结构化注入)在后覆盖之。两行不可调换 @@ -344,8 +356,9 @@ class OpenAICompatTransport: payload = self._build_payload( messages=messages, source=source, profile=profile, stream=stream, overlay=overlay ) - except ValueError as exc: - # 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它 + except ThinkingUnsupportedError as exc: + # 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个 + # 专用类型而非宽 catch ValueError —— 后者会把序列化等无关错误误贴标签 raise RequestRejectedError( f"{source.name} 推理开关无法满足: {exc}", source_name=source.name, diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index 9e2eb33..570ece7 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -31,7 +31,12 @@ import pytest from dotenv import dotenv_values from polygateway import GatewayClient, GatewaySettings -from polygateway.errors import RequestRejectedError +from polygateway.errors import ( + AllSourcesExhausted, + RequestRejectedError, + SourceDeadError, + TransientError, +) from polygateway.providers import DEFAULT_CAPABILITIES, get_capability _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} @@ -236,8 +241,53 @@ class TestMiniMaxM3: async def test_l3_no_opinion_is_the_model_default(self): obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None) - _record("L3", "enable_thinking=None(不干预,基线)", "PASS", "仅记录基线,不断言方向", obs) - assert len(obs) == _ROUNDS + # M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。 + # 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是 + # L2b 的 prompt_tokens 与 L3b 的乱码值反证 + quiet = [o for o in obs if _reasoning_off(o)] + _record( + "L3", + "enable_thinking=None(不干预,基线)", + "PASS" if len(quiet) == len(obs) else "FAIL", + f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)", + obs, + ) + assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}" + + async def test_l3b_none_is_recognised_not_silently_dropped(self): + """反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。 + + L1(关闭)与 L3(不干预)在 M3 上**同分布**——因为 M3 默认档本就不推理。 + 所以 L1 单独看不能区分"`none` 真的被消费"与"`none` 被中转吞了",而后者 + 正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。 + + 判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入" + 一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。 + 既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。 + """ + rounds = max(3, _ROUNDS // 3) + bogus = await _run_rounds( + rounds, + model="MiniMax-M3", + enable_thinking=None, + extra_body={"reasoning_effort": "definitely-not-a-real-level"}, + ) + off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False) + bogus_on = [o for o in bogus if _reasoning_on(o)] + off_quiet = [o for o in off if _reasoning_off(o)] + ok = len(bogus_on) * 2 > len(bogus) and len(off_quiet) == len(off) + _record( + "L3b", + "非法值反证 none 被识别", + "PASS" if ok else "FAIL", + f"非法值 {len(bogus_on)}/{len(bogus)} 轮推理,none {len(off_quiet)}/{len(off)} 轮不推理" + "(两者表现不同 ⇒ none 非被丢弃)", + bogus + off, + ) + assert len(bogus_on) * 2 > len(bogus), ( + f"非法值未开启推理,无法排除'未知值被静默丢弃'这一伪解释: {bogus}" + ) + assert len(off_quiet) == len(off), f"none 未关闭推理: {off}" async def test_l4_extra_body_overrides_the_profile(self): """profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。 @@ -291,7 +341,9 @@ class TestOtherProviders: desc = f"{provider} enable_thinking=False" try: obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False) - except Exception as exc: # 渠道未开通/下线: 记为未覆盖 + except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: + # 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** —— + # 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默 _skip_if_unreachable(exc, matrix, desc) offs = [o for o in obs if _reasoning_off(o)] _record( @@ -328,7 +380,7 @@ class TestCapabilityDrift: return try: obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False) - except Exception as exc: + except (AllSourcesExhausted, SourceDeadError, TransientError) as exc: _skip_if_unreachable(exc, "L8", desc) offs = [o for o in obs if _reasoning_off(o)] verdict = Counter(_reasoning_off(o) for o in obs) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 2e85b56..5ac6bbd 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -7,6 +7,7 @@ import json import httpx import pytest +from loguru import logger from polygateway.errors import ( RequestRejectedError, @@ -557,6 +558,45 @@ class TestRequestShaping: with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"): await _complete(_transport_for(handler), source) + async def test_unregistered_model_warns_only_once_per_source(self): + """未登记模型的告警不能打在请求热路径上: 装配期已喊过,逐次再喊是刷屏。""" + + def handler(request): + return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) + + source = _source(name="mm", provider="minimax", model="MiniMax-M99", enable_thinking=False) + transport = _transport_for(handler) + messages: list[str] = [] + sink_id = logger.add(messages.append, level="WARNING") + try: + await _complete(transport, source) + await _complete(transport, source) + await _complete(transport, source) + finally: + logger.remove(sink_id) + hits = [m for m in messages if "MiniMax-M99" in m] + assert len(hits) == 1, f"三次调用应只告警一次,实得 {len(hits)} 次" + + async def test_unrelated_value_error_is_not_mislabelled(self, monkeypatch): + """只捕 ThinkingUnsupportedError: 无关的 ValueError 不该被贴成推理开关的错。 + + 今天 `_build_payload` 里只有 resolve_thinking 会抛 ValueError,所以这条 + 是防御未来 —— 但正因如此才要钉住: 将来谁在那里加一处校验,宽 catch 会 + 把它的错误信息盖掉,而这个用例会先红。 + """ + + def handler(request): # pragma: no cover - 不该走到发请求 + raise AssertionError("请求不该发出") + + def _boom(*args, **kwargs): + raise ValueError("故意的无关错误") + + monkeypatch.setattr("polygateway.transports.openai_compat.resolve_thinking", _boom) + with pytest.raises(ValueError, match="故意的无关错误") as exc: + await _complete(_transport_for(handler), _source(enable_thinking=False)) + assert "推理开关" not in str(exc.value) + assert not isinstance(exc.value, RequestRejectedError) + async def test_unknown_shape_is_rejected(self): def handler(request): # pragma: no cover - 不该走到发请求 raise AssertionError("请求不该发出") From 5eb01a0096ce8f13552939eb2b351fbbaf4eb97c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 2 Aug 2026 08:12:01 -0400 Subject: [PATCH 7/7] chore: release 1.0.6 and keep the live matrix out of the CI gate The thinking matrix had been running inside make ci all along, which is not what the design claimed. It takes seven minutes, spends 137 real calls, and its criteria are statistical, so a network hiccup fails the build for reasons unrelated to the change under test -- one run died on three consecutive network errors exhausting the source. The project already has the mechanism for this: the slow marker, which addopts excludes by default and the config comments describe as "CI runs it on demand". Marking the matrix slow brings make ci back down from seven minutes to ninety seconds while the matrix stays a merge requirement via -m slow. The design also claimed e2e does not run in CI. It does: make test runs pytest over tests/, e2e included, and the existing smoke tests really call the gateway whenever .env has credentials. Only slow-marked tests are excluded. Both documents now say so. Version sources are pyproject and __init__; a test enforces they agree, and it caught the second one being missed. --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- .../2026-08-02-thinking-capability-design.md | 8 ++++++-- .../plans/2026-08-02-thinking-capability.md | 2 +- src/polygateway/__init__.py | 2 +- tests/e2e/test_thinking_live.py | 13 ++++++++++--- 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 175a6d4..ff182ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 未发布(issue #5 + #6) +## 1.0.6(2026-08-02) 推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。 diff --git a/pyproject.toml b/pyproject.toml index c6cfc37..73aea52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.0.5" +version = "1.0.6" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" requires-python = ">=3.11" dependencies = [ diff --git a/research-wiki/designs/2026-08-02-thinking-capability-design.md b/research-wiki/designs/2026-08-02-thinking-capability-design.md index ebe6f93..271186a 100644 --- a/research-wiki/designs/2026-08-02-thinking-capability-design.md +++ b/research-wiki/designs/2026-08-02-thinking-capability-design.md @@ -185,9 +185,13 @@ R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律 |---|---|---| | unit | `resolve_thinking` 真值表(R1–R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**(CI 可跑) | | integration | 遥测两后端新列写入与 ALTER 迁移 | **是** | -| **e2e(真实 API)** | 见 9.2 | 不进 CI 自动门,但**合并前必须真跑并存档报告** | +| **e2e(真实 API)** | 见 9.2 | 打 `slow` 标记被默认排除;**合并前必须 `-m slow` 真跑并存档报告** | -e2e 不进 CI 自动门的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404。让外部波动阻断合并,会把测试变成噪声源。但"不自动门控"不等于"可跳过"——沿用项目既有 e2e 的口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着"验收前必须真跑")。 +不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。 + +**实现机制**:给本组打项目既有的 `slow` 标记。`pyproject.toml` 的 `addopts = "-m 'not slow'"` 默认排除它(该配置的注释原文:「慢速测试,CI 按需跑」),合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果:`make ci` 由 7 分钟降至 91 秒。 + +**一处必须澄清的事实**:`make test` 跑的是 `pytest tests/`,**包含 `tests/e2e/`**——只要 `.env` 有凭据,既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**,只有打了 `slow` 的才被排除;本节初稿写成前者,是错的。「不自动门控」也不等于「可跳过」——沿用既有口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」)。 ### 9.2 e2e 覆盖矩阵 diff --git a/research-wiki/plans/2026-08-02-thinking-capability.md b/research-wiki/plans/2026-08-02-thinking-capability.md index 43ac10b..b386122 100644 --- a/research-wiki/plans/2026-08-02-thinking-capability.md +++ b/research-wiki/plans/2026-08-02-thinking-capability.md @@ -215,7 +215,7 @@ def _fingerprint_mark(s: SourceConfig) -> str: - [ ] **文件**:新建 `tests/e2e/test_thinking_live.py` -**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**。 +**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**:另加项目既有的 `slow` 标记,靠 `pyproject.toml` 的 `addopts = "-m 'not slow'"` 把本组挡在 `make ci` 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 `network_error` 耗尽源)。合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。 **源映射**:L1–L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*`(`MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*` 与 `{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 88d63f9..cef3568 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -31,7 +31,7 @@ from polygateway.types import ( SourceConfig, ) -__version__ = "1.0.5" +__version__ = "1.0.6" __all__ = [ "DEFAULT_PROFILES", diff --git a/tests/e2e/test_thinking_live.py b/tests/e2e/test_thinking_live.py index 570ece7..3ec76d1 100644 --- a/tests/e2e/test_thinking_live.py +++ b/tests/e2e/test_thinking_live.py @@ -42,9 +42,16 @@ from polygateway.providers import DEFAULT_CAPABILITIES, get_capability _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} _HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) -pytestmark = pytest.mark.skipif( - not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)" -) +# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发 +# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试 +# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并 +# 存档报告。"不自动门控"不等于"可跳过"。 +pytestmark = [ + pytest.mark.slow, + pytest.mark.skipif( + not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)" + ), +] _OUT_DIR = Path("tests/outputs/e2e") _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))