Files
PolyGateway/research-wiki/plans/2026-09-04-reasoning-effort.md
T

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.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 容易在签名上分叉。