CHANGELOG gets all five breaking changes, not the one the draft had: ThinkingCapability's constructor, two ports that grew a parameter with no default, resolve_thinking's new return type, and ProviderProfile's single wire field. Behaviour changes get their own section, including the one that is easy to miss — the openai fallback segment no longer refuses an unknown shape, so a downstream that parks a foreign model there and asks for thinking used to fail at assembly and now sends nothing at all. minimax is called out as the exception it is: the gateway proved M3 does not think without a parameter, so that segment keeps its medium and its downstreams see no change this release. The capability table is reported as it stands — 17 of 24 rows measured, 7 still on documentation, with the reason each one went unmeasured, so nobody reads "measured" into a row that is a guess. The auto limitation and its deliberate MiniMax-M3 inconsistency are written down rather than left for someone to trip over; issue #21 holds the real fix. ARCHITECTURE had five claims that measurement showed had gone false: the cache key formula, the field count, the reconcile predicate and its throttle key, and two field lists. README's FIELD set was missing the two new keys it calls exhaustive. docs-convention still opened by announcing a 17-page site that has not existed since August. It now says what is actually there — one placeholder page pointing at .env.example, CHANGELOG and the source docstrings — and says which four files carry the sync gate while the site is down.
31 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 | 该请求档所需的形态未知(请求 none 看 wire.off,其余档看 wire.on_base) |
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 映射到 HIGH(与 MAX 等距,按「等距取弱」规则走——初稿此处写 MAX 是笔误,规则优先于例子)。候选剔除 none: 否则 (none, auto) 模型上请求 high 会被映射成 none,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。auto 不受 Phase 5 清单约束: 它在请求体里是「不写 effort_key」而非某个取值,可满足性只取决于 on_base 在不在;否则 enable_thinking=True → AUTO 会让存量源当场报错(能力表里档位型模型都不含 auto)。
测试(先失败后通过,五关各一条,兑现设计 §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 不得裸发)。CHANGELOG 必须覆盖三条行为变更,漏第三条是独立验证点名的风险: ①
ThinkingCapability构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注medium;③openai兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配ENABLE_THINKING=true的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。 - 版本号
1.3.3(2026-09-05 人类指令;不因破坏性变更走 minor),pyproject.toml与src/polygateway/__init__.py两处一致。本任务只 bump 不发布——发布走 CLAUDE.md §4.4.1 全清单。
验收: make ci 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
验证: conda run -n PolyGateway make ci → PASS
- 提交:
docs: cut 1.3.3 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 容易在签名上分叉。