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.
This commit is contained in:
2026-08-02 05:49:55 -04:00
parent 781579bf36
commit e5871cccd2
4 changed files with 288 additions and 2 deletions
@@ -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 矩阵 L1L9 |
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
**任务顺序不可调换**T1T3 先把 `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 不经标准 loggingpytest 的 `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/`。**不新造开关机制**。
**源映射**L1L5、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_<ts>.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。