Files
PolyGateway/research-wiki/plans/2026-08-02-thinking-capability.md
iomgaa 5eb01a0096 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.
2026-08-02 08:12:01 -04:00

22 KiB
Raw Permalink Blame History

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 矩阵 L1L9
CHANGELOG.md / research-wiki/schemas/llm-calls.md 修改 行为变更说明与字段表 21 → 22

任务顺序不可调换T1T3 先把 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 Falsecapability.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.pysrc/polygateway/transports/openai_compat.pysrc/polygateway/middleware/retry.py;改测 tests/unit/test_types.pytests/unit/test_openai_compat.pytests/unit/test_retry.py

行为LLMResponseTransportResult 尾部各加 reasoning_tokens: int | None = None(字段顺序是公共承诺,见 types.py:1-5,只增不删不改名)。新增 _coerce_reasoning_tokens,语义与 _coerce_cached_tokensopenai_compat.py:161-177)逐条对齐:非 dict 返回 Nonecompletion_tokens_detailsdict 返回 Nonebool 显式排除(isinstance(True, int) 为真,放行会把 True 记成 1);负数返回 None0 如实保留。流式(: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: 7reasoning_tokens == 7;该键为 00(不与 None 混同);completion_tokens_details 缺失 / 非 dict / 值为 True / 值为 -1 → 均为 Nonemissing_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.pysrc/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.pysrc/polygateway/middleware/telemetry.py;改测 tests/unit/test_ports.pytests/unit/test_telemetry.pytests/integration/test_postgres_telemetry.py

行为record_llm_callsampling 之后追加 reasoning_tokens: int | None(不设默认值——库外无第三方实现者,见 ports.py:250 注释)。两个后端在建表 DDL、_BACKFILL_COLUMNSsqlite/ 迁移语句列表(postgres)、_COLUMNS 三处各加一项,新列必须排在末尾(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。middleware/telemetry.py_record 加参数,三个 emit_* 入口按 cached_prompt_tokens 的既有形态填值:emit_attemptresponse.reasoning_tokens if response else Noneemit_cache_hit 原样回放,emit_terminal_failureNone

不改 pricing.py:推理 token 已含在 completion_tokens 内,单列计价即重复计费。

验收:新建库与经 ALTER 迁移的旧库物理列序一致;reasoning_tokens=7 / 0 / None 三种值各自如实落库(0NULL 可区分);遥测写失败仍降级为 warning 不冒泡。

测试证据:先扩字段清单断言 → 失败 → 实现 → 通过。

验证conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v → PASSconda 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)——它正是把「不支持」与「未知」编码成同一个值的根因。

minimaxthinking_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 Noneqwen / 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

行为:按「关键接口」一节的签名实现 ThinkingCapabilityDEFAULT_CAPABILITIESget_capabilityregister_capabilityresolve_thinking。注册表用 MappingProxyType 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 register_providerproviders.py:84-90)同形。

DEFAULT_CAPABILITIES 首发五条,evidence 逐条写明实测日期与样本量:

can_disable evidence 要点
MiniMax-M3 True 2026-08-02 实测 N=10reasoning_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 并产生一条 warningloguru 不经标准 loggingpytest 的 caplog 抓不到——必须复用项目既有写法 logger.add(messages.append, level="WARNING"),见 tests/unit/test_config.py:29-31);enable_thinking=False + MiniMax-M2.7ValueError 且消息含模型名与"无法关闭"字样;enable_thinking 任意非 None + openai profile 抛 ValueError 且消息含 register_providerextra_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_settingsfrom_env 各增加 capabilities 参数并透传给 OpenAICompatTransportfrom_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_providerclient.py:248openai_compat.py:313 双点调用的既有形态一致。

其二,build_model_fingerprintclient.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.7GatewaySettingsfrom_settingsValueErrorprovider=openai + 任意非 Noneenable_thinkingValueError

验收:装配期报错两例;改 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.tomladdopts = "-m 'not slow'" 把本组挡在 make ci 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 network_error 耗尽源)。合并前用 pytest -m slow tests/e2e/test_thinking_live.py 显式真跑。

源映射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 / L2stream=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.mdresearch-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 / 新能力」一行,需改 参考-公共APILLMResponse 新字段)与相关指南页;该表把同步绑定在变更上而非发版上,不可推迟。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。