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.
21 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| plan | plan:2026-08-02-thinking-capability | 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6) | 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 新增:
@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(= 能力未知,由调用方决定退化)。"""
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 新增:
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 时指纹字面量与改动前逐字相同:
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_<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。