Files
PolyGateway/research-wiki/plans/2026-09-04-reasoning-effort.md
T
iomgaa e9607b2f0c docs: cut 1.3.3 notes for the tier work
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.
2026-09-05 11:22:01 -04:00

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 修改 导出 EffortThinkingWire
.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.pytests/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(改)

行为:

  1. types.py 新增 Effort_ORDER(见上)。放 types.py 而非 thinking.py: 它是 SourceConfig/ChatRequest 的字段类型,定义在决策模块会让 types.py 反向 import(依赖铁律)。
  2. ThinkingCapability 改为 supported_efforts + evidence,加两个 @property 派生量。构造期校验: supported_efforts 非空、元素唯一、全部属 Effort,违反即 ValueError
  3. DEFAULT_CAPABILITIES 按设计 §8 落库规则重写(见下表)。
  4. 迁移三处既有读点: thinking.py 内部读 capability.can_disable 改为读派生属性(行为不变);tests/unit/test_thinking.pyThinkingCapability(True, "实测") 位置参数构造改为关键字构造;tests/e2e/test_thinking_live.pycan_disable 处确认派生属性可用。
  5. __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-5gemini-3-flashkimi-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: Efforttypes.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(改)

行为:

  1. 新增 ThinkingWire(见关键接口)。None 的语义严格沿用 issue #5: on_base is None = 开启形态未知(请求开启档时报错),off is None = 该 provider 无关闭形态,effort_key is None = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
  2. ProviderProfile.thinking_on/thinking_off 两字段替换为 thinking: ThinkingWire
  3. 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 同步导出 ThinkingWireopenai 段的两档由 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 该请求档所需的形态未知(请求 nonewire.off,其余档看 wire.on_base) ThinkingUnsupportedError,指路 register_provider/extra_body
3 capability is None warn_unregistered 为真时 warning,随后按 wire 注入,不校验档位
4 effort is NONEnot capability.can_disable ThinkingUnsupportedError,文案含 cheapest_effort 与 env 键名
5 effort not in supported_effortsfallback == "error" ThinkingUnsupportedError;文案按 capability.is_tiered 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 auto/none」(设计 §3.2 第三个派生量的用途)

Phase 4 必须先于 5: none 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。

注入形态:

  • effort is NONEwire.off;wire.off is None 时报错(该 provider 无关闭形态)。
  • effort is AUTOwire.on_base(不附档位)。这与旧 thinking_on 逐字节等价。
  • 其余档 → {**wire.on_base, wire.effort_key: effort.value};effort_key is None 时报错并说明该 provider 只有开关没有档位。

nearest 映射(fallback == "nearest",人类 2026-09-04 复核确认实现): 按 _ORDERsupported_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(改)

行为:

  1. SourceConfig 末尾追加 reasoning_effort: Effort | None = Noneeffort_fallback: str = "error"
  2. config.py_SOURCE_FIELDS 增两行: "REASONING_EFFORT": ("reasoning_effort", "effort")"EFFORT_FALLBACK": ("effort_fallback", "str")。新增 "effort" 解析类型: 值必须属 Effort 取值域,否则报错并列出八档。
  3. effort_fallback 值域 {"error", "nearest"},越界即报错(与 _SELECTORS/_QUOTA_FULL 同款 frozenset 校验)。
  4. 矛盾校验(构造期): 同源同时给出 enable_thinkingreasoning_effort 且语义冲突时 ValueError。冲突定义: enable_thinking is Truereasoning_effort is NONE;或 enable_thinking is Falsereasoning_effort not in (None, Effort.NONE)。二者一致(如 False + none)则放行。
  5. .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(改)

行为:

  1. ChatRequest 末尾追加 reasoning_effort: Effort | None = None
  2. GatewayClient.chat() 增关键字参数 reasoning_effort: Effort | None = None,存入 ChatRequest
  3. 新增纯函数(放 thinking.py,与其余推理决策同处):
def effective_effort(
    *, request_effort: Effort | None, source_effort: Effort | None,
    enable_thinking: bool | None,
) -> Effort | None:
    """生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""

语法糖映射: TrueEffort.AUTO(注入 on_base,与旧行为逐字节等价,且不依赖能力表);FalseEffort.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 的优先级落不了地。

行为:

  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:72tests/unit/test_backpressure.py:213tests/integration/test_redis_cross_connection.py:76)——@runtime_checkable 只查方法名不查签名,漏改会在调用时 TypeError,且错误现场离根因很远。

不动: EmbeddingTransportOcrTransport 两个协议——它们无推理语义(与 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_effortChatRequest 一致)、::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 形状)

行为:

  1. _fingerprint_mark: 源级 reasoning_effort 仅在非 None追加,规则与 enable_thinking 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
  2. build_cache_key 增关键字参数 reasoning_effort: Effort | None = None,仅非 None写入 key_obj["reasoning_effort"]
  3. 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.pysrc/polygateway/ports.pysrc/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.pysrc/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_callreasoning_effort: str | None(不设默认值,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
  3. 两个 recorder 落库新列。
  4. middleware/telemetry.py: _record 加参并传给 recorder(唯一 record_llm_call 调用点,不复制参数列表);emit_attemptapplied_effort 关键字参数,由其三个调用方传值——retry.py:411 传实际档,embedding.py:407ocr.py:451None(无推理语义)。三个 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 落库,NoneNULL——与 thinking_observation 同一先例(StrEnumstr 子类,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(改)

行为:

  1. _build_payload: 用 effective_effort(...) 求生效档位后调 resolve_thinking(..., fallback=source.effort_fallback)。注入结果仍先于 source.extra_bodyoverlay(顺序即优先级,issue #4 决策 A,两行不可调换)。
  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 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
    • RetryMWretry.py:375TransportResult → 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(版本号)

行为:

  1. make lint + make test 全绿;make format
  2. CHANGELOG 加「未发布」段: 破坏性变更(ThinkingCapability 构造签名)、新增(八档 Effort、两个 env 键、遥测新列、四个 provider 段)、行为变更(openai 段两档由未知改为 OpenAI 标准形态)。
  3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。CHANGELOG 必须覆盖三条行为变更,漏第三条是独立验证点名的风险: ① ThinkingCapability 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 medium;③ openai 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 ENABLE_THINKING=true 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
  4. 版本号 1.3.3(2026-09-05 人类指令;不因破坏性变更走 minor),pyproject.tomlsrc/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 容易在签名上分叉。