---
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")` 读凭据、`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` 约定跳过，并在报告中记为「未覆盖」，**不得静默计入通过**。

覆盖矩阵（轮数经环境变量可调，默认值如下）：

| # | 场景 | 源 | 轮数 | 判据 |
|---|---|---|---|---|
| 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` → 装配期报错 | — | — | 纯本地，无需真实调用 |

**三条必须遵守的测试纪律**：

其一，**判别量只能是 `reasoning_tokens`**。（执行时按 e2e 实测修正：本条初稿写的是「主判据用 `completion_tokens`」，被数据推翻——两档的输出长度分布**重叠**，关闭档实测最高 46、开启档最低 13，按长度阈值判两个方向都会误判。）`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路（findings §4c、§2.5）。

其二，**关闭方向要求每轮满足，开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到，关闭方向不受影响。

其四，**必须有不依赖输出侧噪声的锚点**：L2b 比较两档的 `prompt_tokens`（相对比较，无魔数），L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态，不排除它，关闭方向的证据就只到「未回归」，够不到「已生效」。

其三，**源不可用必须跳过并在报告中显式记为「未覆盖」**，不得静默计入通过（实测中 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。
