30 KiB
实现计划: 推理档位一等化
- 设计:
research-wiki/designs/2026-09-04-reasoning-effort-design.md(2026-09-04 人类已批准) - 目标: 把
enable_thinking: bool | None升级为可表达厂商档位的Effort词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。 - 方案概述: 新增八档封闭枚举
Effort(含auto);能力表从can_disable: bool改为supported_efforts: tuple[Effort, ...];provider 的两个固定片段改为ThinkingWire(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。 - 涉及技术: Python 3.12
StrEnum、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。 - 保真校验: 不适用。本计划实现的是库自研的推理决策(
thinking.py系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且reference/三项目当前不在工作区(见设计 §12),无可比对源。
文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
src/polygateway/types.py |
修改 | 新增 Effort 枚举;SourceConfig/ChatRequest 各加档位字段 |
src/polygateway/thinking.py |
修改 | ThinkingCapability 重构、resolve_thinking 五关、reconcile_thinking 判据、默认能力表重写 |
src/polygateway/providers.py |
修改 | ThinkingWire 新类型替换两个片段;DEFAULT_PROFILES 扩到 8 段 |
src/polygateway/config.py |
修改 | 两个新 env 键的解析与矛盾校验 |
src/polygateway/client.py |
修改 | chat() 签名加档位;_fingerprint_mark 纳入源级档位 |
src/polygateway/middleware/cache.py |
修改 | build_cache_key 纳入请求级档位 |
src/polygateway/middleware/telemetry.py |
修改 | _record 与三个 emit 入口传递生效档位 |
src/polygateway/ports.py |
修改 | TelemetryRecorder.record_llm_call 加一参(25 → 26 字段) |
src/polygateway/telemetry/schema.py |
修改 | COLUMNS、两端 DDL、补列声明 |
src/polygateway/telemetry/{sqlite,postgres}.py |
修改 | 落库新列 |
src/polygateway/transports/openai_compat.py |
修改 | 生效档位解析接线、告警节流键 |
src/polygateway/__init__.py |
修改 | 导出 Effort、ThinkingWire |
.env.example |
修改 | 两个新键的模板与注释 |
tests/unit/test_thinking.py |
修改 | 位置参数构造迁移 + 五关用例 |
tests/unit/test_providers.py |
修改 | ThinkingWire 用例 |
tests/unit/test_cache.py |
修改 | 档位进 key 的用例 |
tests/unit/test_openai_compat.py |
修改 | transport 接线与节流用例 |
tests/unit/test_telemetry.py、tests/integration/test_redis_cache.py |
修改 | 列数断言与缓存 key 回归 |
tests/e2e/test_thinking_live.py |
修改 | can_disable 读法迁移;新增逐模型档位实测(标 slow) |
关键接口(跨任务消费,此处定稿)
# types.py
class Effort(StrEnum):
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
# providers.py
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
name: str
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
# thinking.py
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...]
evidence: str
@property
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,
capability: ThinkingCapability | None,
effort: Effort | None,
*,
model: str,
fallback: str = "error", # "error" | "nearest"
warn_unregistered: bool = True,
) -> ThinkingResolution: ...
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None: ...
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
# SourceConfig: reasoning_effort: Effort | None = None
# effort_fallback: str = "error"
# ChatRequest: reasoning_effort: Effort | None = None
Task 1 — Effort 词汇与能力表重构
文件: src/polygateway/types.py(改)、src/polygateway/thinking.py(改)、src/polygateway/__init__.py(改)、tests/unit/test_thinking.py(改)、tests/e2e/test_thinking_live.py(改)
行为:
types.py新增Effort与_ORDER(见上)。放types.py而非thinking.py: 它是SourceConfig/ChatRequest的字段类型,定义在决策模块会让types.py反向 import(依赖铁律)。ThinkingCapability改为supported_efforts+evidence,加两个@property派生量。构造期校验:supported_efforts非空、元素唯一、全部属Effort,违反即ValueError。DEFAULT_CAPABILITIES按设计 §8 落库规则重写(见下表)。- 迁移三处既有读点:
thinking.py内部读capability.can_disable改为读派生属性(行为不变);tests/unit/test_thinking.py的ThinkingCapability(True, "实测")位置参数构造改为关键字构造;tests/e2e/test_thinking_live.py读can_disable处确认派生属性可用。 __init__.py导出Effort(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
初始 DEFAULT_CAPABILITIES(evidence 一律以 2026-09-04 文档推定(来源),待经 new-api 实测 开头):
| model | supported_efforts |
|---|---|
glm-5.3, glm-5.3-flash |
(LOW, HIGH, MAX) |
glm-5.2 |
(NONE, HIGH, MAX) |
glm-5, glm-5.1, glm-4.6v |
(NONE, AUTO) |
deepseek-v4-pro, deepseek-v4-flash, deepseek-v4-flash-vision-exp |
(NONE, HIGH, MAX) |
gpt-5.4, gpt-5.5 |
(NONE, LOW, MEDIUM, HIGH, XHIGH) |
claude-opus-5, claude-sonnet-5 |
(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX) |
gemini-3.1-pro |
(LOW, MEDIUM, HIGH) |
kimi-k3 |
(LOW, HIGH, MAX) —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 none |
MiniMax-M3 |
(NONE, AUTO) |
MiniMax-M2.7, MiniMax-M2.5 |
(AUTO,) |
qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus |
(NONE, AUTO) |
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;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_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 条)
- 提交:
refactor: make capability a tier list, since "can it be off" is one entry in it
Task 2 — ThinkingWire 与 8 段 provider 表
文件: src/polygateway/providers.py(改)、src/polygateway/__init__.py(改)、tests/unit/test_providers.py(改)
行为:
- 新增
ThinkingWire(见关键接口)。None的语义严格沿用 issue #5:on_base is None= 形态未知(报错),off is None= 该 provider 无关闭形态,effort_key is None= 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。 ProviderProfile.thinking_on/thinking_off两字段替换为thinking: ThinkingWire。DEFAULT_PROFILES由 4 段扩到 8 段:
| provider | off | on_base | effort_key |
|---|---|---|---|
qwen |
{"enable_thinking": False} |
{"enable_thinking": True} |
None |
deepseek |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
zhipu |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
moonshot |
{"thinking": {"type": "disabled"}} |
{"thinking": {"type": "enabled"}} |
"reasoning_effort" |
minimax |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
openai |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
anthropic |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
google |
{"reasoning_effort": "none"} |
{} |
"reasoning_effort" |
__init__.py 同步导出 ThinkingWire。openai 段的两档由 None(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 reasoning_effort 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 register_provider。
验收: get_provider("zhipu").thinking.effort_key == "reasoning_effort";未注册名仍报错且错误文案列出全部 8 段;register_provider 仍返回新表不改共享状态。
测试(先失败后通过): tests/unit/test_providers.py::test_all_eight_profiles_registered、::test_wire_none_semantics_distinct(三种 None 各自的含义不混淆)。
验证: conda run -n PolyGateway pytest tests/unit/test_providers.py -v → PASS
- 提交:
feat: give zhipu, moonshot, anthropic and google a wire of their own
Task 3 — resolve_thinking 五道关卡与 nearest 映射
文件: src/polygateway/thinking.py(改)、tests/unit/test_thinking.py(改)
行为: 按下表实现,顺序不可调换,每关的理由写进 docstring。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | effort is None |
返回 {} |
| 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;文案按 capability.is_tiered 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 auto/none」(设计 §3.2 第三个派生量的用途) |
Phase 4 必须先于 5: none 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
注入形态:
effort is NONE→wire.off;wire.off is None时报错(该 provider 无关闭形态)。effort is AUTO→wire.on_base(不附档位)。这与旧thinking_on逐字节等价。- 其余档 →
{**wire.on_base, wire.effort_key: effort.value};effort_key is None时报错并说明该 provider 只有开关没有档位。
nearest 映射(fallback == "nearest",人类 2026-09-04 复核确认实现): 按 _ORDER 在 supported_efforts 中取距请求档位序最近者,等距时取弱侧(省钱优先,不替下游涨价);AUTO 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。effort is NONE 且不可关时不走映射——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
验收: 五关各自触发与不触发;medium 在 (LOW, HIGH, MAX) 上 nearest 映射到 LOW(等距取弱);minimal 映射到 LOW;xhigh 映射到 MAX。
测试(先失败后通过,五关各一条,兑现设计 §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
- 提交:
feat: refuse an impossible tier with the cheapest one that model does have
Task 4 — 源级配置入口
文件: src/polygateway/types.py(改)、src/polygateway/config.py(改)、.env.example(改)、tests/unit/test_config.py(改)
行为:
SourceConfig末尾追加reasoning_effort: Effort | None = None与effort_fallback: str = "error"。config.py的_SOURCE_FIELDS增两行:"REASONING_EFFORT": ("reasoning_effort", "effort")、"EFFORT_FALLBACK": ("effort_fallback", "str")。新增"effort"解析类型: 值必须属Effort取值域,否则报错并列出八档。effort_fallback值域{"error", "nearest"},越界即报错(与_SELECTORS/_QUOTA_FULL同款 frozenset 校验)。- 矛盾校验(构造期): 同源同时给出
enable_thinking与reasoning_effort且语义冲突时ValueError。冲突定义:enable_thinking is True且reasoning_effort is NONE;或enable_thinking is False且reasoning_effort not in (None, Effort.NONE)。二者一致(如False+none)则放行。 .env.example加两键模板,注释写明八档取值、与ENABLE_THINKING的等价关系及矛盾会报错。
验收: LLM__ZHIPU__1__REASONING_EFFORT=low 解析为 Effort.LOW;写 lowest 报错且文案列出八档;ENABLE_THINKING=true + REASONING_EFFORT=none 构造期报错。
测试(先失败后通过): tests/unit/test_config.py::test_effort_key_parsed、::test_invalid_effort_lists_vocabulary、::test_contradictory_thinking_flags_rejected、::test_consistent_flags_allowed。
验证: conda run -n PolyGateway pytest tests/unit/test_config.py -v → PASS
- 提交:
feat: let a source name its reasoning tier, and say so when it contradicts itself
Task 5 — 请求级入口与优先级
文件: src/polygateway/types.py(改)、src/polygateway/thinking.py(改,effective_effort 定义处)、src/polygateway/client.py(改)、tests/unit/test_client.py(改)
行为:
ChatRequest末尾追加reasoning_effort: Effort | None = None。GatewayClient.chat()增关键字参数reasoning_effort: Effort | None = None,存入ChatRequest。- 新增纯函数(放
thinking.py,与其余推理决策同处):
def effective_effort(
*, request_effort: Effort | None, source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
语法糖映射: True → Effort.AUTO(注入 on_base,与旧行为逐字节等价,且不依赖能力表);False → Effort.NONE;None → 不表态。
验收: 三层优先级各自生效;请求级 None 不会覆盖源级已配的档;只配 enable_thinking=True 的存量源解析为 AUTO 且最终 payload 与升级前逐字节相同。
测试(先失败后通过): ::test_request_effort_wins_over_source、::test_none_request_does_not_clear_source、::test_enable_thinking_true_is_auto、::test_legacy_on_tier_matches_old_fragment(回归门: 仅对 on_base 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 medium、新版不注入,是设计 §4.2 声明过的有意变更)。
验证: conda run -n PolyGateway pytest tests/unit/test_client.py -v → PASS
- 提交:
feat: let one call ask for a different tier than its source defaults to
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 的优先级落不了地。
行为:
Transport.complete协议增关键字参数reasoning_effort: Effort | None。不设默认值——与TelemetryRecorder同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。RetryMW._attempt调用处传request.reasoning_effort。该中间件此前只读request的五个字段,新增第六个,不改其他语义。OpenAICompatTransport.complete接收并透传给_build_payload。- 三个测试 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 形状)
行为:
_fingerprint_mark: 源级reasoning_effort仅在非None时追加,规则与enable_thinking完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。build_cache_key增关键字参数reasoning_effort: Effort | None = None,仅非None时写入key_obj["reasoning_effort"]。CacheMW.__call__传request.reasoning_effort。
为什么两处都要(写进注释): model_fingerprint 是装配期算的集合级指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
验收: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
测试(先失败后通过): ::test_request_tier_changes_key、::test_absent_tier_keeps_legacy_key(断言具体 key 字符串不变)、::test_source_tier_enters_fingerprint。
验证: conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v → PASS
- 提交:
fix: keep a low-tier answer out of the cache slot a max-tier one filled
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 签名断言)、tests/integration/test_postgres_telemetry.py(改——该文件有 _EXPECTED_COLUMNS 完整列序断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、src/polygateway/middleware/retry.py(改,emit_attempt 调用点传新参)
行为:
schema.py:COLUMNS末尾加"reasoning_effort"(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加reasoning_effort TEXT(位置与 ALTER 追加一致);补列声明同步。列数断言按物理列写——两套口径混用是本模块最易错处(见其 docstring)。ports.py:record_llm_call加reasoning_effort: str | None(不设默认值,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。- 两个 recorder 落库新列。
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 |
成功时 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 |
同上(可能根本没选出源) |
与 sampling 列的现有做法同构(emit_attempt 合并源级,另两处只取请求级)。
5. 值为 Effort 时取 .value 落库,None 落 NULL——与 thinking_observation 同一先例(StrEnum 是 str 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
验收: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 NULL;telemetry_schema_sql 打印的 SQL 与库实际执行的 DDL 同源。
测试(先失败后通过): 既有遥测列数断言用例更新;::test_effort_column_records_effective_tier、::test_cache_hit_records_request_tier_only、::test_absent_tier_is_null。
验证: conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v → PASS
- 提交:
feat: record which tier a call actually ran at
Task 8 — transport 接线与对账
文件: src/polygateway/transports/openai_compat.py(改)、src/polygateway/thinking.py(改)、tests/unit/test_openai_compat.py(改)
行为:
_build_payload: 用effective_effort(...)求生效档位后调resolve_thinking(..., fallback=source.effort_fallback)。注入结果仍先于source.extra_body与overlay(顺序即优先级,issue #4 决策 A,两行不可调换)。_warn_on_thinking_mismatch的节流键由(source.name, source.model, source.enable_thinking)改为(source.name, source.model, effective_effort)——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。reconcile_thinking签名的enable_thinking: bool | None改为effort: Effort | None,判据:effort is NONE对应原「要求关闭」分支,effort为其余档对应原「要求开启」分支,None仍返回None。不新增「档位高低 vsreasoning_tokens多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。ThinkingUnsupportedError的捕获与翻译路径不变(→RequestRejectedError,不重试不换源不计熔断)。- 把实际档送出 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 改档位);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_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
- 提交:
feat: wire the tier through the transport and keep each tier's warning distinct
Task 9 — 全套件、文档与 wiki
文件: CHANGELOG.md、.env.example(复核)、Gitea Wiki(按 research-wiki/docs-convention.md §2)、src/polygateway/__init__.py(版本号)、pyproject.toml(版本号)
行为:
make lint+make test全绿;make format。- CHANGELOG 加「未发布」段: 破坏性变更(
ThinkingCapability构造签名)、新增(八档Effort、两个 env 键、遥测新列、四个 provider 段)、行为变更(openai段两档由未知改为 OpenAI 标准形态)。 - 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。
- 版本号
1.4.0(minor: 公共 API 破坏性变更),两处一致。本任务只 bump 不发布——发布走 CLAUDE.md §4.4.1 全清单。
验收: make ci 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
验证: conda run -n PolyGateway make ci → PASS
- 提交:
docs: cut 1.4.0 notes for the tier work
Task 10 — e2e 实测校正初始能力表(标 slow)
文件: 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 中的「文档推定」。
为什么必须单列一个任务: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
验收: 每个已登记模型有一条实测记录;与文档推定不符者更新 supported_efforts 并在 evidence 记明分歧(尤其 kimi-k3 的保守登记、gemini-3.1-pro 的默认档两源打架)。
验证: conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v → PASS(约 20-40 分钟,取决于网关)
- 提交:
test: replace the guessed tier table with what the gateway actually does
执行顺序与依赖
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
T2(wire) ─────────┘ ↑
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
│ │
└─→ T6(缓存 key) ↓
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
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 容易在签名上分叉。