From de261e485dd6e6e012647d28815dee24c099550a Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 5 Sep 2026 00:21:29 -0400 Subject: [PATCH] docs: fix the three places the plan could not actually execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../plans/2026-09-04-reasoning-effort.md | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/research-wiki/plans/2026-09-04-reasoning-effort.md b/research-wiki/plans/2026-09-04-reasoning-effort.md index afc3c65..7872e17 100644 --- a/research-wiki/plans/2026-09-04-reasoning-effort.md +++ b/research-wiki/plans/2026-09-04-reasoning-effort.md @@ -73,6 +73,19 @@ class ThinkingCapability: def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts @property 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( profile: ProviderProfile, @@ -82,7 +95,7 @@ def resolve_thinking( model: str, fallback: str = "error", # "error" | "nearest" warn_unregistered: bool = True, -) -> Mapping[str, Any]: ... +) -> ThinkingResolution: ... def reconcile_thinking( *, @@ -131,9 +144,9 @@ def reconcile_thinking( `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 条) @@ -185,7 +198,7 @@ def reconcile_thinking( | 2 | `profile.thinking.on_base is None` | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` | | 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** | | 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」,丢掉「这个模型根本关不掉」与可执行替代。 @@ -198,7 +211,8 @@ Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支 **验收**: 五关各自触发与不触发;`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 @@ -229,7 +243,7 @@ Phase 4 必须先于 5: `none` 只是 5 的特例,落进 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`。 @@ -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 **文件**: `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` 列 -**文件**: `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)。 2. `ports.py`: `record_llm_call` 加 `reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。 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_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 是两个独立的矛盾,共用一个键会让第二个永久静音。 3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。 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 @@ -346,7 +388,7 @@ def effective_effort( ## 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` 中的「文档推定」。 @@ -363,15 +405,14 @@ def effective_effort( ## 执行顺序与依赖 ``` -T1(词汇+能力表) ──┬─→ T3(五关) ──→ T8(transport) -T2(wire) ─────────┘ ↑ -T4(源级) ──┬─→ T5(请求级) ──┬────────┘ - │ ├─→ T6(缓存 key) - │ └─→ T7(遥测) - ↓ - T9(文档) → T10(实测) +T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport) +T2(wire) ─────────┘ ↑ +T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤ + │ │ + └─→ T6(缓存 key) ↓ + T7(遥测) ─→ 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 容易在签名上分叉。