docs: fix the three places the plan could not actually execute

Codex found the per-call tier never reaches the transport: the protocol
takes five unpacked arguments, not the request, so a field on ChatRequest
goes nowhere. That is now its own step, fakes included.

It also found the mapped tier would be lost — resolve_thinking returned
only the payload, so telemetry would file a mapped call under a tier it
never ran at, which is exactly the grouping task 10 depends on.
This commit is contained in:
2026-09-05 00:21:29 -04:00
parent e01420178f
commit de261e485d
@@ -73,6 +73,19 @@ class ThinkingCapability:
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
@property @property
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档 def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
@property
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
@dataclass(frozen=True)
class ThinkingResolution:
"""注入片段 + **实际**生效档。
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
"""
payload: Mapping[str, Any]
applied_effort: Effort | None # Phase 1(不表态)为 None
def resolve_thinking( def resolve_thinking(
profile: ProviderProfile, profile: ProviderProfile,
@@ -82,7 +95,7 @@ def resolve_thinking(
model: str, model: str,
fallback: str = "error", # "error" | "nearest" fallback: str = "error", # "error" | "nearest"
warn_unregistered: bool = True, warn_unregistered: bool = True,
) -> Mapping[str, Any]: ... ) -> ThinkingResolution: ...
def reconcile_thinking( def reconcile_thinking(
*, *,
@@ -131,9 +144,9 @@ def reconcile_thinking(
`claude-haiku-5``gemini-3-flash``kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。 `claude-haiku-5``gemini-3-flash``kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort``(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;空元组构造报 `ValueError` **验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort``(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered``(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)``(AUTO,)` 为假;空元组构造报 `ValueError`
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable``::test_cheapest_effort_skips_none``::test_empty_efforts_rejected` **测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable``::test_cheapest_effort_skips_none``::test_is_tiered_excludes_none_and_auto``::test_empty_efforts_rejected`
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort``types.py` 不得产生反向依赖,设计 §13 第 6 条) **验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort``types.py` 不得产生反向依赖,设计 §13 第 6 条)
@@ -185,7 +198,7 @@ def reconcile_thinking(
| 2 | `profile.thinking.on_base is None` | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | | 2 | `profile.thinking.on_base is None` | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** | | 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
| 4 | `effort is NONE``not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 | | 4 | `effort is NONE``not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
| 5 | `effort not in supported_efforts``fallback == "error"` | `ThinkingUnsupportedError`,列出该模型可选档 | | 5 | `effort not in supported_efforts``fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。 Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
@@ -198,7 +211,8 @@ Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支
**验收**: 五关各自触发与不触发;`medium``(LOW, HIGH, MAX)``nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 `MAX` **验收**: 五关各自触发与不触发;`medium``(LOW, HIGH, MAX)``nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 `MAX`
**测试**(先失败后通过): `::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT`)、`::test_nearest_ties_go_cheaper``::test_none_never_maps``::test_auto_injects_on_base_only``::test_effort_key_none_rejects_tier` **测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing``::test_phase2_unknown_wire_points_to_register``::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model``::test_phase5_says_toggle_only_for_switch_model`
另: `::test_nearest_ties_go_cheaper``::test_none_never_maps``::test_auto_injects_on_base_only``::test_effort_key_none_rejects_tier``::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS **验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
@@ -229,7 +243,7 @@ Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支
## Task 5 — 请求级入口与优先级 ## Task 5 — 请求级入口与优先级
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改) **文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
**行为**: **行为**:
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None` 1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`
@@ -256,6 +270,30 @@ def effective_effort(
--- ---
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
**行为**:
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72``tests/unit/test_backpressure.py:213``tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
**不动**: `EmbeddingTransport``OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort``ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
---
## Task 6 — 缓存 key ## Task 6 — 缓存 key
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状) **文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
@@ -279,17 +317,17 @@ def effective_effort(
## Task 7 — 遥测新增 `reasoning_effort` 列 ## Task 7 — 遥测新增 `reasoning_effort` 列
**文件**: `src/polygateway/telemetry/schema.py``src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言) **文件**: `src/polygateway/telemetry/schema.py``src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
**行为**: **行为**:
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。 1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
2. `ports.py`: `record_llm_call``reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。 2. `ports.py`: `record_llm_call``reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
3. 两个 recorder 落库新列。 3. 两个 recorder 落库新列。
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);三个入口取值口径分列: 4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt``applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407``ocr.py:451``None`(无推理语义)。三个 emit 入口取值口径分列:
| 入口 | 取值 | 理由 | | 入口 | 取值 | 理由 |
|---|---|---| |---|---|---|
| `emit_attempt` | `effective_effort(...)` 的结果 | 有选中源,能算出真正生效的档 | | `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 | | `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) | | `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
@@ -315,10 +353,14 @@ def effective_effort(
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。 2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。 3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。 4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
- `RetryMW``retry.py:375``TransportResult → LLMResponse` 转换处带上该字段。
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位)。 **验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected``::test_no_retry_on_tier_error``::test_throttle_key_separates_tiers``::test_reconcile_none_vs_observed` **测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected``::test_no_retry_on_tier_error``::test_throttle_key_separates_tiers``::test_reconcile_none_vs_observed``::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS **验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
@@ -346,7 +388,7 @@ def effective_effort(
## Task 10 — e2e 实测校正初始能力表(标 `slow`) ## Task 10 — e2e 实测校正初始能力表(标 `slow`)
**文件**: `tests/e2e/test_thinking_live.py`(改) **文件**: `tests/e2e/test_thinking_live.py`(改)`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。 **行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
@@ -363,15 +405,14 @@ def effective_effort(
## 执行顺序与依赖 ## 执行顺序与依赖
``` ```
T1(词汇+能力表) ──┬─→ T3(五关) ──→ T8(transport) T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
T2(wire) ─────────┘ ↑ T2(wire) ─────────┘
T4(源级) ──┬─→ T5(请求级) ──┬────────┘ T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
├─→ T6(缓存 key)
│ └─→ T7(遥测) └─→ T6(缓存 key) ↓
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
T9(文档) → T10(实测)
``` ```
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);T6/T7 依赖 T5(请求级字段);T8 依赖 T3+T5;T9 在功能任务全绿后;T10 最后且独立(标 `slow`) T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。 执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。