21 Commits

Author SHA1 Message Date
iomgaa 8ced49a515 chore: release 1.1.0
Minor rather than major: adding a parent class widens what an existing
`except` catches, it does not break one. Callers already catching
GovernanceBackendError keep working untouched.

The wiki sync in the release checklist is a no-op this time. The doc site
was taken down entirely on 2026-08-02 for accuracy reasons, and its
remaining landing page points at CHANGELOG.md as the version source of
truth, which this commit updates.
2026-08-06 05:16:53 -04:00
iomgaa 77f9260189 docs: publish which errors reach callers and which the library absorbs
TransientError and SourceDeadError read like caller-facing contracts in
the taxonomy table, but the retry loop catches both and repackages them as
AllSourcesExhausted, so they never arrive. That is only discoverable by
reading middleware/retry.py, and a downstream project wrote a whole design
section on the false premise before checking.

The new table states the split outright, including that
GovernanceBackendError now sits on the caller-facing side and
SourceNotConfiguredError deliberately does not join the retryable family.
2026-08-06 05:04:55 -04:00
iomgaa 45073486a7 fix: reparent governance backend failures under GatewayUnavailableError (issue #7)
A fail-closed limiter or breaker backend means the scope cannot emit a
single request, which is exactly scope-level unavailability. But the error
sat directly under PolyGatewayError, so a caller writing only
`except GatewayUnavailableError` dropped it into the catch-all branch:
Redis blips once and a backlog of tasks burns its business failure budget
into the dead letter queue, over a fault a restart would clear.

Three gate paths leak to callers rather than being absorbed by
_record_quietly (try_acquire, try_enter, progress_age_s); each is now
pinned by a test, since none of them had one before.

The two unknown-source sites move to SourceNotConfiguredError instead of
following along. They report a misconfigured source name, not an outage,
and letting them into the retryable family would be the mirror of the bug
being fixed here: the task would retry forever and never surface.
2026-08-06 04:53:52 -04:00
iomgaa dd540496a1 feat: add SourceNotConfiguredError and the governance backend reason
Pure addition ahead of the reparenting, so this commit leaves every
existing caller and test untouched.

SourceNotConfiguredError deliberately stays outside GatewayUnavailableError:
a source name that is not in the limiter's config dict is an assembly
defect, not a transient outage, and folding it into the retryable family
would let a typo retry forever without ever reaching a dead letter queue.

The retry_after_s default is 5.0 rather than 0 because a backlog released
at zero delay would stampede a backend that is already down.
2026-08-06 04:36:46 -04:00
iomgaa c634cab35e docs: admit governance backend failures into the scope-level error model
GovernanceBackendError arrived with the M2 distributed backends but never
made it into the section 6.1 table, so it had no place in the taxonomy
callers actually read. That omission is why the README missed it too.

Records the reparenting, the new governance_backend_down reason, and why
SourceNotConfiguredError deliberately stays outside the reparented family:
a misconfigured source name must burn its failure budget and surface,
not retry forever in silence.
2026-08-06 04:22:35 -04:00
iomgaa 1fa91cf73d docs: add the implementation plan for issue #7
Five tasks, with the ARCHITECTURE section 6.1 revision first so the code
never contradicts the single source of truth, and the reparenting kept
atomic because scope is a required keyword argument and any split would
leave an unrunnable tree.

The Codex review caught that the planned test evidence pointed at the
wrong stubs: the ones at test_backpressure.py:176-186 cover accounting-side
degradation, not the three gate paths that actually leak to callers, and
try_acquire and try_enter have no stub at all.
2026-08-06 04:11:17 -04:00
iomgaa 3a104fcce4 docs: record human approval of the issue #7 design
All three open decisions were settled as proposed: a dedicated
SourceNotConfiguredError so a misconfigured source reaches the dead
letter queue instead of retrying forever, a 5 second retry_after_s so a
backlog does not stampede a backend that is already down, and public
export so callers can alarm on assembly defects specifically.
2026-08-06 03:56:41 -04:00
iomgaa a8cca51164 docs: require subagents and Codex to run in the foreground
Background dispatch produced two failure modes this session, and both
looked identical from the outside: a run that had finished without anyone
noticing, and a run that had wedged without anyone noticing. A pipe on the
end of the command masked pytest's real exit code as 0, and a waiter
looping on `pgrep -f "<the command>"` matched its own command line and
never terminated. Foreground execution trades parallelism for knowing
what actually happened.
2026-08-06 03:49:32 -04:00
iomgaa b1109e9fe9 docs: fold the Codex review into the issue #7 design and register it
Pins the ARCHITECTURE section 6.1 revision to land before or with the
implementation, since the new scope reason contradicts the current single
source of truth. Documents why SourceNotConfiguredError may sit outside
the four-way classification: that rule governs transport-translated call
failures, and the GatewayUnavailableError family already lives outside it.

Also collapses the ten per-field response ternaries in emit_attempt into
an _AttemptUsage view. They all expressed the same decision and pushed the
method to cyclomatic complexity C, which blocked the commit gate.
2026-08-06 03:45:48 -04:00
iomgaa 2f5abb6a55 docs: design the governance backend error reclassification (issue #7)
Fail-closed governance backend failures are semantically scope-level
unavailability, yet GovernanceBackendError sits directly under
PolyGatewayError, so callers writing only `except GatewayUnavailableError`
drop them into the catch-all bucket and burn their failure budget on a
fault that a restart would clear.

The design reparents it under GatewayUnavailableError with a new
governance_backend_down reason, splits the two "unknown source" sites into
a separate assembly-defect error so a misconfiguration still reaches the
dead letter queue, and picks a non-zero retry_after_s to avoid a
zero-delay retry storm against a backend that is already down.
2026-08-06 02:33:06 -04:00
iomgaa a1a9212ba1 docs: state the real downstream impact of the M2.x refusal
The design claimed merging would immediately break dissect. It would
not: dissect keeps running whatever version it already has, and this
release does not touch it. What is true is narrower -- once dissect
moves to 1.0.6, the M2.7 scope will refuse to assemble.

Worth recording because the distinction is not academic here.
dissect/requirements.txt declares polygateway>=1.0.1,<1.1, a range
rather than a pin, so 1.0.6 satisfies it and any routine reinstall picks
it up without anyone deciding to upgrade. So it is not "breaks on
merge", it is "breaks on the next dependency install".

The paragraph now carries both corrections it went through, since a
claim about downstream impact that was wrong twice is worth leaving
visible rather than quietly rewriting.
2026-08-02 08:22:55 -04:00
iomgaa 7adcfff0fa feat: make the thinking switch real and collect reasoning tokens (issue #5, #6)
enable_thinking=False was a silent no-op for minimax and openai sources.
The shape of the switch now stays at provider level while a model-level
capability table says whether a given model can honour it at all, and
reasoning_tokens is collected so the cost of thinking can be told apart
from the cost of answering.

Verified against the live gateway: a seventeen-row matrix over 137 real
calls, kept out of the CI gate behind the slow marker.
2026-08-02 08:14:52 -04:00
iomgaa 5eb01a0096 chore: release 1.0.6 and keep the live matrix out of the CI gate
The thinking matrix had been running inside make ci all along, which is
not what the design claimed. It takes seven minutes, spends 137 real
calls, and its criteria are statistical, so a network hiccup fails the
build for reasons unrelated to the change under test -- one run died on
three consecutive network errors exhausting the source.

The project already has the mechanism for this: the slow marker, which
addopts excludes by default and the config comments describe as "CI runs
it on demand". Marking the matrix slow brings make ci back down from
seven minutes to ninety seconds while the matrix stays a merge
requirement via -m slow.

The design also claimed e2e does not run in CI. It does: make test runs
pytest over tests/, e2e included, and the existing smoke tests really
call the gateway whenever .env has credentials. Only slow-marked tests
are excluded. Both documents now say so.

Version sources are pyproject and __init__; a test enforces they agree,
and it caught the second one being missed.
2026-08-02 08:12:01 -04:00
iomgaa 48805cb9fb fix: address the independent verification findings (issue #5, #6)
The verifier caught that the disable-direction evidence only proved "no
regression", not "actually took effect": on M3 the disabled runs and the
no-opinion baseline are identically distributed, because that model does
not reason by default anyway. So the disable runs alone cannot rule out
the very failure mode issue #5 is about -- the parameter being silently
dropped upstream. The bogus-value experiment that does rule it out was
sitting in the findings document instead of the test suite; it is now
case L3b, and the L3 assertion that could never fail is gone.

Also from the review: the e2e helper caught bare Exception, which would
have disguised a library bug as an unavailable source, exactly the
silence the reporting discipline exists to prevent; the unregistered
model warning fired on every request instead of once per source; and the
transport caught ValueError broadly enough to mislabel unrelated errors,
now narrowed to a dedicated ThinkingUnsupportedError.

The design and plan still described the original judgement criteria,
which the measurements had already overturned. Both now match what the
tests actually do, and the design no longer claims the only new failure
surface is the openai one -- dissect configures MiniMax-M2.7 with
ENABLE_THINKING=false and will fail at assembly, which has to be
coordinated before this merges.
2026-08-02 07:40:06 -04:00
iomgaa 4c135075b3 test: verify the thinking switch against the live API (issue #5, #6)
A sixteen-row matrix over 127 real calls: disable and enable on
MiniMax-M3 in both streaming and non-streaming mode, extra_body winning
over the profile slot, qwen and deepseek still disabling correctly, a
drift sentinel that re-derives every registered capability from live
behaviour, and the assembly guard refusing the models that cannot
comply.

Two judgement criteria had to be corrected by the data they were meant
to judge. Output length cannot separate the two regimes at all -- the
disabled runs reach 46 tokens when the model narrates its working in
the visible answer, and the enabled runs drop to 13 when medium effort
barely thinks. reasoning_tokens separates them cleanly in both
directions, which is precisely what issue #6 was collected for. A
second anchor compares prompt_tokens between the two regimes: the
vendor injects a reasoning instruction when thinking is on, so the
input side grows, and comparing the two runs relatively avoids
hardcoding any vendor number.

Provider names are mapped explicitly rather than guessed from the model
string; guessing had silently skipped the qwen row behind a "source
unavailable" reason that was not true.
2026-08-02 06:55:38 -04:00
iomgaa 82f4ec4910 feat: model the thinking switch as shape plus capability (issue #5)
enable_thinking=False was a no-op for minimax and openai sources: both
profiles had empty dicts on each side, so the payload update injected
nothing while the caller believed reasoning had been turned off. A
downstream project was blocked on exactly this.

The root cause is that an empty dict meant two different things -- "no
injection needed" and "we do not know how this provider spells it" --
and that a provider-level table cannot express what turned out to be a
per-model property. Live testing showed MiniMax-M3 can disable
reasoning via reasoning_effort while M2.7 and M2.5 cannot be disabled
at all, which two external registries independently confirm.

So the shape stays at provider level and a capability table joins it at
model level. Unknown, unsupported and no-opinion are now three distinct
values, and resolve_thinking is the single place they meet: it raises at
assembly time when a model cannot honour the request, warns and injects
for unregistered models, and injects silently otherwise. Every registered
capability carries the evidence it was derived from.

enable_thinking also joins the cache fingerprint, since it now really
does change the request body.
2026-08-02 06:20:24 -04:00
iomgaa 89ff916bc8 feat: collect reasoning_tokens from the provider usage payload (issue #6)
Reasoning tokens are already counted inside completion_tokens, so the
cost total was never wrong -- what was missing is the attribution: how
much of a call was spent thinking rather than answering.

LLMResponse and TransportResult each gain a trailing reasoning_tokens
field, and the telemetry port grows from 21 to 22 columns with the new
column appended in both backends so fresh and migrated schemas keep the
same physical order.

None means this particular call did not report the field, not that the
source never reports it: a relay that falls back to a local tokenizer
replaces the whole usage object and drops completion_tokens_details.
Downstream checks must therefore read "in (None, 0)"; no provider was
observed reporting a literal zero.
2026-08-02 05:55:37 -04:00
iomgaa e5871cccd2 docs: add implementation plan for thinking capability and reasoning tokens
Ten tasks in a fixed order: land reasoning_tokens first so it can serve
as the acceptance instrument for the thinking-switch fix, then reshape
the provider profile, add the model-level capability table, wire the
assembly guard, fold enable_thinking into the cache fingerprint, and
verify the whole thing against the live API.

Incorporates a read-only Codex review: resolve_thinking now takes the
model name so its errors can name it, and the warning assertion uses a
loguru sink because caplog cannot see loguru output.
2026-08-02 05:49:55 -04:00
iomgaa 781579bf36 docs: record thinking-switch findings and capability design (issue #5, #6)
Findings: live-API measurements across MiniMax M3/M2.7/M2.5, qwen and
deepseek, plus a survey of how nine unified gateways model per-model
parameter divergence. Key facts: reasoning_effort is MiniMax's real
switch, M2.x reasoning is mandatory and cannot be disabled, and the
relay's local token-count fallback silently drops reasoning_tokens.

Design: keep the parameter shape at provider level, push capability
down to model level, split "unknown" / "unsupported" / "no opinion"
into three distinct values, and fail at assembly time when a model
cannot honour enable_thinking=False.
2026-08-02 05:42:05 -04:00
iomgaa acc419a29b chore: add a mechanical wiki-vs-source alignment checker
七轮人工审查的 88 条发现里,签名/导出/字段序/列数/env 键这几类是机械可
比对的,不该靠人一轮轮追。五项检查全部由源码反推,已用注入历史错误的方式
验证有效: gather_bounded(coros, limit)、source_name 排进前 11 位、列数写
成 20、EXTRA_BODY 漏文档 —— 四条全部命中。

不并入 make ci: wiki 是独立仓库,仓库里没有它时自动跳过等于静默降级(违
P5),故做成显式的 make wiki-check WIKI=<path>。
2026-08-02 02:16:16 -04:00
iomgaa de7273598e docs: correct the scope normalization comment on Redis key impact
Both Redis backends have lowercased scope in their own constructors since
v1.0.0, so case never split the keyspace. What actually does is whitespace:
the backends lower but do not strip.
2026-08-02 00:38:42 -04:00
53 changed files with 3050 additions and 121 deletions
+49
View File
@@ -1,5 +1,54 @@
# Changelog
## 1.1.0(2026-08-06)
治理后端故障归位为 scope 级不可用(issue #7)。限流/熔断的状态后端(Redis 等)自身故障时,库按降级方向铁律 fail-closed——**整个 scope 一个请求都发不出去**,语义上就是"scope 级暂时不可用"。但 `GovernanceBackendError` 此前是 `PolyGatewayError` 的直接子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果很具体: Redis 抖一下,积压任务一批批消耗业务失败预算,够到上限就进死信——**而那是运维重启一下就好的故障**。
### 行为变更(**请先读这一条**)
- **`GovernanceBackendError` 现在能被 `except GatewayUnavailableError` 捕获。** 它改为继承该类,`reason` 恒为新增的 `governance_backend_down`。**下游对后端故障的处置路线因此改变**: 从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是本次修复的目标,但升级前请确认下游的兜底分支没有依赖旧行为(例如靠它触发告警)。既有的 `except GovernanceBackendError` **继续有效**——加父类是扩大捕获面,不是破坏。
- **配置写错(源名与限流后端配置不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`。** 该类**有意不在** `GatewayUnavailableError` 之下: 那是装配缺陷不是暂时故障,必须消耗失败预算、进死信、让人看见。若随整类归入可重投家族,配置写错的任务会永远重投且无人告警——恰是本次要修的 bug 的镜像。
- **`GovernanceBackendError` 的构造签名增加必填 keyword `scope`。** 库内 20 处构造点已全部更新;若下游有自行构造该异常的代码(罕见)需同步补 `scope`
### 新增
- **`SourceNotConfiguredError`**(公共导出)。源名不在限流后端配置字典中时抛出,正常不可达,属装配缺陷。
- **`GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`**,`GovernanceBackendError.retry_after_s` 的默认值。**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),故取保守固定值。**不取 0**: 那会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成一场风暴。
- **scope 级 `reason` 值域增 `governance_backend_down`**(由 5 值扩为 6 值)。
- **README 新增"哪些异常会到达调用方"两列表**。四分类里 `TransientError` / `SourceDeadError` 被重试循环接住、耗尽时包成 `AllSourcesExhausted`,**根本到不了调用方**,而这只看类型树与 docstring 读不出来——曾让下游据此写错整段设计文档。
### 下游请读
- **`GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`**,与 `AllSourcesExhausted` 同款;`str(exc)` 仍是原来的诊断串(如 `限流后端 try_acquire 失败: ...`),结构化字段与诊断信息并存,排障不受影响。
- **三条闸门路径**(`try_acquire` / `try_enter` / `progress_age_s`)的后端故障会到达调用方;记账路径(`record_success` 等)仍被 `_record_quietly` 降级为 warning,这个分工不变。
- **CHSAnalyzer 迁移**: `tracking.py` 一条 `except GatewayUnavailableError` 即覆盖完整,无需为后端故障单列分支(`migrations/chsanalyzer.md` G1 已补注)。
## 1.0.6(2026-08-02)
推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。
### 行为变更(**请先读这一条**)
- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none``True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。
- **`MiniMax-M2.7` / `MiniMax-M2.5``ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。
- **`provider=openai` 的源配任何非 `None``ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。
- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。
### 新增
- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。
- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。
- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。
- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。
### 下游请读
- **`reasoning_tokens``None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`
- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`
- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。
- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。
- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
## 1.0.5(2026-07-31)
采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。
+6
View File
@@ -28,6 +28,12 @@ make ci # 只读验证(check + test)
> **档位原则(Fable 5 适配,2026-07 调研决策)**: 约束"边界与验收",不规定思考步骤。强制档(MANDATORY)是硬门;其余由模型按 skill description 自判,自判标准是任务实质(规模/风险/是否触及公共承诺),不是省事。硬边界(reference/ 只读、危险命令、提交质量门)由 `.claude/settings.json` 注册的 hooks **确定性执行**,不依赖提示词自觉。
> [!CRITICAL]
> **执行模式: subagent 与 Codex 一律前台(2026-08-06 人类指令)**
> 一切 subagent(verifier、`subagent-driven-development` 执行器、Explore 等)与 Codex 调用**必须前台运行**——`Agent` 工具传 `run_in_background: false`,`/codex:rescue` 带 `--wait`,**禁止**后台派发后继续做别的事。
> **理由(实测教训)**: 后台完成通知不可靠——管道会掩盖真实退出码(`pytest ... | tail` 让失败跑报成 exit 0),等待脚本的 `pgrep -f` 会自匹配成死循环,于是出现"任务早完成却没人知道"和"任务挂了也没人知道"两种失败,且两种都以"看起来还在跑"的形态呈现,无法从外部区分。前台运行牺牲并行度换取状态确定性,这个交换在本项目是划算的。
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
### Phase 1: 规划与设计
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
2. 功能产生运行时数据时**必须**调用 `structured-logging`
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: install test lint format check ci wiki
.PHONY: install test lint format check ci wiki wiki-check
ENV := PolyGateway
@@ -24,3 +24,10 @@ ci: check test
wiki:
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
# 用户文档站(Gitea Wiki)与源码的机械对齐校验。wiki 是独立仓库,须显式给路径:
# make wiki-check WIKI=~/PolyGateway.wiki
# 不并入 ci: 仓库里没有 wiki,自动跳过等于静默降级(违 P5),宁可让人显式跑。
wiki-check:
@test -n "$(WIKI)" || (echo "用法: make wiki-check WIKI=<PolyGateway.wiki 克隆路径>" && exit 1)
conda run -n $(ENV) python3 tools/check_wiki_alignment.py --wiki $(WIKI)
+15
View File
@@ -124,6 +124,21 @@ except RequestRejectedError:
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
### 哪些异常会到达调用方
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
| 会到达调用方 | 库内吸收(不必 catch) |
|---|---|
| `GatewayUnavailableError` 族——`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError` | `TransientError`(退避后换源重试,耗尽即转为 `AllSourcesExhausted`) |
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
| `ResultInvalidError` | |
| `SourceNotConfiguredError` | |
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
## 配置参考
配置只有两条装配路径:`from_env()`(读 `.env`/环境变量)或构造函数全量注入(测试/高级);库内部任何组件不自读环境变量。键名全集见 [.env.example](.env.example),约定速览:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.0.5"
version = "1.1.0"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
requires-python = ">=3.11"
dependencies = [
+7 -1
View File
@@ -376,8 +376,14 @@ flowchart TB
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
| `ResultInvalidError` | 调用成功但内容不可解析(JSON 修不好、ZIP 缺关键文件) | ❌(仅 D14 结构化阶梯的有界带反馈重问,不入 transport 重试计数) | ❌ | ❌(熔断记**成功**) |
| `CircuitOpenError` / `AllSourcesExhausted` | 开路 / 全源耗尽 | 调用方决定: wait / fail-fast 可配 | — | — |
| `GovernanceBackendError` | 限流/熔断**状态后端自身**故障(Redis 挂等);降级方向 fail-closed,故一个请求都发不出去 | 调用方决定(同 scope 级: 延期重投) | — | — |
| `SourceNotConfiguredError` | 源名不在限流后端配置字典中——**装配缺陷**,非调用失败,正常不可达 | ❌ | ❌ | ❌ |
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources;`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
**scope 级不可用的结构化语义(2026-07-20,CHS 迁移缺口 G1;2026-07-20 M1 设计勘误修订)**: `AllSourcesExhausted`/`CircuitOpenError` 必须携带结构化字段——`retry_after_s: float`(**非可选**,承 CHS `ProviderUnavailableError` 同款,0 表示可立即重试;取各源冷却与 Retry-After 的最小值)、`reason` 枚举、`per_source_reasons: dict[str, str]`。reason 两层值域(M1 设计 §3 勘误: 本节初版所列 7 值与 CHS `errors.py:143-153` 实际值域不符,重组如下)——scope 级 `reason`: circuit_open / retry_exhausted / stalled / quota_exhausted / no_sources / **governance_backend_down**(2026-08-06 增,见下);`per_source_reasons` 值: network_error / timeout / rate_limited / source_dead / circuit_open / cooldown。CHS 的"scope 级不可用 → arq 延期重投、不消耗业务失败预算"(`workers/tracking.py:406-428`)依赖 `retry_after_s` 复现。
**治理后端故障归位(2026-08-06,Gitea issue #7;设计 `designs/2026-08-06-governance-backend-error-design.md`)**: `GovernanceBackendError` 自 M2 引入分布式后端时新增,但**当时未回补本表**,于是它在"调用方视角的分类学"里一直没有位置——本次归位同时补上这个遗漏。它此前是 `PolyGatewayError` 的直接子类,而语义上 fail-closed 意味着整个 scope 发不出任何请求,正是 scope 级不可用;下游只写 `except GatewayUnavailableError` 会把它落进兜底分支,导致"Redis 抖一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。现改为继承 `GatewayUnavailableError`,`reason` 恒为 `governance_backend_down`,`retry_after_s` 默认取常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`——**不取 0**,因为后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),而 0 会让积压任务零延迟同时冲击已挂掉的后端。
同批拆出 `SourceNotConfiguredError`: 限流后端 `_cfg()` 遇到源名不在配置字典中时原先也抛 `GovernanceBackendError`,但那是装配缺陷而非后端故障。若随整类归入"可延期重投",配置写错的任务会**永远重投、永不进死信、无人告警**——恰是本次要修的 bug 的镜像。故它有意留在 `GatewayUnavailableError` 之外,让缺陷消耗失败预算并浮出水面。它与四分类的关系见 §6.3 之外的第三论域说明: 四分类的论域是 transport 层翻译的**调用失败**(§6.2),scope 级不可用回答"整个 scope 还能不能用",而装配缺陷根本不该进入治理循环被"决定"。
### 6.2 翻译规则(transport 层职责)
@@ -0,0 +1,270 @@
---
type: design
node_id: design:2026-08-02-thinking-capability-design
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
date: 2026-08-02
---
# 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6
> 类型:design|日期:2026-08-02|状态:待人类确认
> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`(本文所有实测引用均出自该文)。
> 本设计经 2026-08-02 充分讨论后直接给出单一方案,不列备选。
## 1. 问题
**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关,靠 `providers.py``ProviderProfile.thinking_on/thinking_off` 落地。`minimax``openai` 两格皆为空 dict`_build_payload``payload.update({})` 是空操作:`enable_thinking=False` 对这两类源**完全不产生效果**,而配置方以为关掉了。
这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`,并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。
**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确(推理 token 已含在 `completion_tokens` 内),但"本次调用有多少钱花在推理上"无法区分,而这正是 dissect 要测的因子的主要成本通道。
**两者的耦合。** #6#5 的验收仪器:修完 #5 后判断"这次是否真的没推理",靠正文长度不可靠,靠 `reasoning_content` 也不行(MiniMax 非流式恒为空、正文无 `<think>` 标签)。因此 **#6 先落地,#5 的测试断言它**。
## 2. 根因
空 dict 同时承载了两种语义:「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同,就只能靠"表里没有 = 不发"兜底,静默失效随之产生。
更深一层:`ProviderProfile` 的注册单位是 **provider**,而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关,M2.7 / M2.5 **固有不可关**(三种参数形态实测全部无效,OpenRouter 与 models.dev 独立登记为 mandatory)。provider 级的表在物理上表达不了这件事。
业界佐证:注册单位下沉到 model 级的(LiteLLM、models.dev、LangChain、OpenRouter、Helicone)都有显式失败通道;仍停在 provider 级的(Portkey、LlamaIndex)恰是失败语义最差的两家,均静默丢弃。**注册粒度与失败语义是同一个问题的两面。**
## 3. 决策摘要
| # | 决策 |
|---|---|
| D1 | **形态留 provider 级,能力下沉 model 级**。形态 = 参数长什么样(数年不变);能力 = 能否关闭(每代都变) |
| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**,落在三个不同层次 |
| D3 | **遇到"关不掉"的模型报错,不静默放行**;报错在装配期,请求期兜底 |
| D4 | **「开」的默认档定 `medium`,允许 per-source 覆盖**(经已有 `extra_body`,不新增字段) |
| D5 | `enable_thinking` **纳入缓存指纹**(配套,必做) |
| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」,非「该源未上报」(配套,必做) |
D4 的依据:业界对「开」映射到哪一档**无语义共识**(LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半),唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定,`medium` 是五档中语义最接近"厂商正常强度"的一档;选 `high` 等于库替所有下游做"加钱换质量"的业务判断,违反零业务假设。
## 4. 数据模型
### 4.1 形态层(provider 级)
`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`
| 值 | 含义 | 当前实例 |
|---|---|---|
| `{...}` | 已知的注入片段 | qwen / deepseek / minimax |
| `{}` | 已知**无需注入**即处于该档 | 无(保留为自然零值) |
| `None` | **未知**:库不知道该 provider 如何表达 | `openai` 两档 |
```python
"minimax": ProviderProfile(
name="minimax",
thinking_on={"reasoning_effort": "medium"},
thinking_off={"reasoning_effort": "none"},
strip_think_tags=False,
),
"openai": ProviderProfile(
name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False,
),
```
`openai``None` 而非补 `reasoning_effort`,理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**(`dissect/.env:116``kimi-k3` 挂在 `provider=openai` 下)。向未知厂商下发 `reasoning_effort` 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。
qwen / deepseek 两条实测正确,**不动**。
### 4.2 能力层(model 级,新增)
```python
@dataclass(frozen=True)
class ThinkingCapability:
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
can_disable: bool
evidence: str
```
登记表键为模型名精确匹配,**只登记在用的模型**,未登记即"未知"并走退化路径:
| 模型 | `can_disable` | 证据 |
|---|---|---|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10`reasoning_effort=none` 稳定关闭 |
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter `mandatory:true` |
| `MiniMax-M2.5` | `False` | 同上 |
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 |
注入方式沿用 D11 的纯函数注册纪律:`get_capability(model, *, table=None)``register_capability(...)` 返回新表,经 `capabilities` 参数注入,与现有 `registry` 参数同形,**不引入模块级可变状态**。
**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立(import 期发网络请求)。二者仅作为写表时的对照参考;本次三条 MiniMax 实测与它们的登记 100% 吻合,这本身就是表可信的旁证。
### 4.3 三个值的层次归属(D2)
| 语义 | 载体 | 层次 |
|---|---|---|
| **不干预**(调用方不表态) | `SourceConfig.enable_thinking is None` | 调用方意图 |
| **未知**(库不知道怎么表达) | `ProviderProfile` 该档为 `None` | 形态层 |
| **不支持**(模型做不到) | `ThinkingCapability.can_disable is False` | 能力层 |
三者不可互相替代:不干预是意图缺失,未知是知识缺失,不支持是能力缺失。当前实现把后两者塌缩成空 dict,是 issue #5 的根因。
## 5. 判定与失败语义(D3
单一判定函数收口,形态层与能力层在此相遇:
```python
def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""
```
真值表:
| # | 条件 | 行为 |
|---|---|---|
| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 |
| R2 | 形态层该档为 `None` | **报错**,文案指路 `register_provider``extra_body` |
| R3 | `enable_thinking is False``can_disable is False` | **报错**:调用方要的是"不推理"的语义保证,给不了必须说 |
| R4 | 模型未登记(能力未知) | 按形态层注入 + `loguru.warning`,不阻断 |
| R5 | 其余 | 按形态层注入 |
R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律:**"关不掉"用错的后果是下游带着错误前提做实验(opt-in,从严);"未登记"多为新模型上线(opt-out,从宽)**,误拒会让库成为升级路上的绊脚石。
### 5.1 报错位置:两处,共用同一份判定
| 位置 | 异常 | 覆盖 |
|---|---|---|
| `client.py:from_settings``:248` 已在此解析 profiles | `ValueError`(装配期) | `from_env` / `from_settings` 两条工厂路径,即 90% 场景 |
| `OpenAICompatTransport` | `RequestRejectedError`(四分类之一,不重试不换源) | 构造函数全量注入路径 |
这不是重复判定:`get_provider` 现在就是同一形态(`client.py:248` + `openai_compat.py:313`)。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。
**绝不在 `_build_payload` 里抛裸 `ValueError`**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`
## 6. reasoning_tokens 采集(issue #6
照搬 issue #3`_coerce_cached_tokens` 形态:只收非负整数,显式排除 `bool``isinstance(True, int)` 为真,放行会把 `True` 记成 1)。
`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺(`types.py:1-5`),只增不删不改名。
流式与非流式对称取值:`completion_tokens_details` 在最后的 usage 帧里,`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`(现有代码天然满足:`sink` 无 usage 时 `_coerce_*` 返回 `None`)。
**`pricing.py` 一行不改**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。这是归因缺口,不是计费缺口。
**缓存路径无需改动**`CacheMW._rehydrate``_RESPONSE_FIELDS` 动态过滤(`cache.py:28,133`),旧条目缺该字段自动落 `None`,语义正确。
### 6.1 语义澄清(D6
实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**,无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage,把 ctd 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。因此:
- docstring 写「**本次调用**未上报」,**不可**写「该源未上报」
- 下游判据必须是 `reasoning_tokens in (None, 0)`,写 `== 0` 的条件永远不成立
- 这三句要同时进 docstring、CHANGELOG 与 wiki
## 7. 缓存指纹配套(D5
`build_model_fingerprint``client.py:63-80`)当前只摘要 `(model, extra_body)`#5 一旦让 thinking 真正改变请求体,就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4`temperature` 写过逐字相同的理由。
做法:marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`,摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同,不触发存量缓存冷启动**;dissect 会有一次性冷启动,这是正确行为(旧缓存来自推理开着的调用)。
## 8. 落点清单
| 文件 | 改动 |
|---|---|
| `providers.py` | 两档放宽为 `dict \| None`;填 minimax、`openai``None`;新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` |
| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`;新增 `_coerce_reasoning_tokens`;流式 `:401` 与非流式 `:485` 填值;构造函数收 `capabilities` |
| `client.py` | `from_settings` / `from_env``capabilities``:248` 后加装配守卫;`build_model_fingerprint` 纳入 `enable_thinking` |
| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` |
| `middleware/retry.py` | `_build_response` 透传 |
| `ports.py` | `record_llm_call` 21 → 22 字段 |
| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`,**新列排末尾**(两处注释均有明文要求) |
| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 |
## 9. 测试策略
本次改动的正确性**与具体模型强相关**,mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。
### 9.1 三层分工
| 层 | 内容 | 是否门控合并 |
|---|---|---|
| unit | `resolve_thinking` 真值表(R1R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**CI 可跑) |
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
| **e2e(真实 API** | 见 9.2 | 打 `slow` 标记被默认排除;**合并前必须 `-m slow` 真跑并存档报告** |
不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。
**实现机制**:给本组打项目既有的 `slow` 标记。`pyproject.toml``addopts = "-m 'not slow'"` 默认排除它(该配置的注释原文:「慢速测试,CI 按需跑」),合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果:`make ci` 由 7 分钟降至 91 秒。
**一处必须澄清的事实**`make test` 跑的是 `pytest tests/`**包含 `tests/e2e/`**——只要 `.env` 有凭据,既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**,只有打了 `slow` 的才被排除;本节初稿写成前者,是错的。「不自动门控」也不等于「可跳过」——沿用既有口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」)。
### 9.2 e2e 覆盖矩阵
沿用既有 e2e 约定:`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`,结构化报告输出至 `tests/outputs/e2e/`
| # | 场景 | 源 | 轮数 | 判据 |
|---|---|---|---|---|
| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30``reasoning_tokens``None` |
| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数(基线) |
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`profile 的 `medium` 被覆盖 |
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2(库默认 `stream=True`,这是主路径) |
| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 |
| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 |
| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 |
| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地,无需真实调用 |
轮数由环境变量可调高,默认 ≥10。总量约 100–150 次调用。
### 9.3 三条必须遵守的测试纪律
**a)判别量只能是 `reasoning_tokens`。**2026-08-02 e2e 实测修正:本节初稿写的是"主判据用 `completion_tokens`",被数据推翻。)两档的输出长度分布**重叠**——关闭档实测最高 46(模型偶尔把解题过程写进正文),开启档最低 13(medium 档想得少的轮次),按长度阈值判两个方向都会误判;而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点:关闭档 `prompt_tokens` 严格小于开启档(实测 194 < 207)。
**(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。
**(c)源不可用必须跳过并显式记录为"未覆盖",不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。
### 9.4 漂移哨兵(L8)的定位
能力表过期是必然事件(LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故)。L8 用真实调用反向校验每条登记,是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真,这里会先炸。建议纳入发版前清单定期执行。
## 10. 明确不做
不为中转的观测漂移在库内加任何机制(多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`)——中转路由不受请求参数影响,探测结果不可迁移,属 YAGNI 违规;该问题在运维侧解决,写入 wiki 前提。
不改 `SourceConfig` 的公开字段形态:`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`,两条路径已进缓存 key 与 `sampling` 遥测列,新增字段则要额外接这两处,是隐藏成本。
不动 qwen / deepseek 的 profile;不碰 `pricing.py`;不引入任何新依赖。
## 11. 验收标准
1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`,响应 `reasoning_tokens is None`,真实 API 多轮验证
2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**,文案说明该模型无法关闭推理
3. `ENABLE_THINKING` 任意非 `None` + `provider=openai`**装配期报错**,指路 `register_provider` / `extra_body`
4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning
5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入
6. 流式与非流式均能采到 `reasoning_tokens`;打捞路径记 `None` 而非 `0`
7.`enable_thinking` → 缓存 key 变化;不配该项的存量 scope key 逐字不变
8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写
9. e2e 报告存档于 `tests/outputs/e2e/`,矩阵覆盖情况可核
每条均需"先失败后通过"的证据(测试结果门)。
## 12. 影响与风险
**这是行为变更,不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"CHANGELOG 须醒目标注;dissect 会有一次性缓存冷启动。
**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性,任何库层改动都无法改变。可行替代是只在 M3 上做该对照,或将因子改为"高档 vs 低档"。此结论须同步给 dissect。
**能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。
**新增两处失败面。**(本段两次修正:初稿只列了 `openai` 那一处、遗漏 M2.x;二稿又把 M2.x 那处写成「合并即打挂 dissect」,同样不准确——见下。)
其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。
其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false`。准确的影响是:**dissect 升到 1.0.6 之后**,该 scope 装配会抛 `ValueError`;它当前跑着的版本不受本次发布影响。但 `dissect/requirements.txt:7` 声明的是 `polygateway>=1.0.1,<1.1` —— 一个**范围**而非精确 pin,`1.0.6` 落在范围内,所以任何一次 `pip install -U`、重建环境或 CI 重装依赖都会**自动**装上它,无需谁刻意升级。换言之不是「突然挂」,而是「下次装依赖时挂」。
这是本设计的**预期行为**(给不了「不推理」的语义保证就必须说),dissect 侧的处置是改配置:该对照只能在 M3 上做,或把因子改为「高档 vs 低档」。
**三个参考下游零破坏**VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。
## 13. 另立 issue(不在本次范围)
`kimi-k3` 拒绝 `temperature=0`400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
`qwen``strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。
@@ -0,0 +1,181 @@
# 治理后端故障归位为 scope 级不可用设计(Issue #7)
- **日期**: 2026-08-06
- **来源**: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败,基于 1.0.1 源码核查)
- **状态**: **已批准(2026-08-06)**,待 `writing-plans`
- **触发档位**: 强制(变更 `errors.py` 公共错误类型树 = 库对下游的承诺)
- **方案范围**: 人类已选定方向 A′ 并明确要求单一方案,故本文不列平行备选,仅在 §4 记录被否决路线及否决理由
## 1. 目标与非目标
| | 内容 |
|---|---|
| **G1** | `GovernanceBackendError` 归入 `GatewayUnavailableError` 之下,使"该延期重投的失败"在类型上闭合——调用方一条 `except GatewayUnavailableError` 覆盖完整,漏接在物理上不可能 |
| **G2** | 把混在同一类里的**装配期缺陷**("未知源")拆出去,使其**不**被误判为可重投 |
| **G3** | `retry_after_s` 取非零值,避免后端故障期间下游零延迟批量重投形成忙循环 |
| **G4** | 公开错误面文档化:README 增"会到达调用方 / 库内吸收"两列表,`ARCHITECTURE.md` §6.1 回补缺失的 `GovernanceBackendError` 行 |
| **非目标** | 不改 fail-closed 降级方向(限流/熔断后端不可用 → 报错而非放行,库铁律不动);不改后端重连/健康探测;不新增配置项;不改 `TransientError`/`SourceDeadError` 的库内吸收行为 |
### 1.1 Issue 前提的四处修正(按 1.0.6 源码核实)
| Issue 原文 | 实际情况 |
|---|---|
| 泄漏路径为 `try_enter` / `try_acquire` 两条 | **三条**`middleware/retry.py:216` 每轮循环开头的 `progress_age_s()` 同样在 catch 之外,直达调用方 |
| (未提及构造点数量) | 全库 **22 处** `raise GovernanceBackendError`,分布于 4 个文件 |
| 方向 A 只需改类型树 | 其中 **2 处语义完全不同**(见 §3.4),整类归入"可重投"会制造镜像 bug |
| `retry_after_s` 取 0,「docstring 已写 0 = 可立即重试,语义上是通的」 | 语义通,**工程上不通**。见 §3.2 |
另需记录一处根因:`ARCHITECTURE.md:372-378` §6.1 的错误分类表里 `GovernanceBackendError` **一次都没出现**。它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来就没有位置——README 的遗漏是这个遗漏的下游后果。
## 2. 影响面的决定性前提(改动安全性的依据)
| 事实 | 证据 | 含义 |
|---|---|---|
| 库内仅一处 `except GatewayUnavailableError` | `middleware/telemetry.py:250`,写法为 `except (GatewayUnavailableError, GovernanceBackendError)` | 变成父子关系后该处由"并列捕获"退化为"父类捕获",**行为逐字不变**,库内零回归 |
| 加父类是纯扩大 | 下游既有 `except GovernanceBackendError` 全部照旧命中 | 不违反 CLAUDE.md §4.3「已被下游消费的公共类型只增不删不改名」 |
| `QuotaGate`/`BreakerGate` 是后端异常的唯一入口 | 两类 docstring 自述,三处装配 `retry.py:186` / `ocr.py:122` / `embedding.py:123` | scope 注入点收敛为 2 个类、3 处装配 |
| 三个装配点都持有 `self._scope` | `retry.py:183``ocr.py:116``embedding.py:118` | 注入无需新增上游参数传递链 |
## 3. 选定方案
### 3.1 类型树变更
`SCOPE_REASONS` 增枚举值 `governance_backend_down`;`GovernanceBackendError` 改继承 `GatewayUnavailableError`,`reason` 恒为该值(与 `CircuitOpenError` 恒为 `circuit_open` 同构,是本库已有的表达手法)。
构造签名保持"首参为 message"的位置参数形态,以免 22 处构造点与既有测试全部改写:
```python
class GovernanceBackendError(GatewayUnavailableError):
def __init__(self, message, *, scope, retry_after_s=GOVERNANCE_BACKEND_RETRY_AFTER_S,
source_name=None):
super().__init__(scope=scope, reason="governance_backend_down",
retry_after_s=retry_after_s, source_name=source_name)
self.args = (message,) # 见 §3.5
```
`scope` 为必填 keyword(P4 显式优于隐式:它在三层调用点全部可得,给默认值只会掩盖装配疏漏)。
### 3.2 `retry_after_s` 的取值(本设计的核心权衡)
Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重投,Redis 挂掉期间队列里积压的任务将以零延迟批量重投,对着一个已经挂掉的后端打忙循环——把一次故障放大成一场风暴。这与本 issue 想修的问题同源:都是"分类正确但处置参数错误"。
已考虑并否决的两个替代取值:
| 取值 | 否决理由 |
|---|---|
| 复用 `BackpressureConfig.poll_interval_s`(与 `quota_exhausted` 同源,`retry.py:299` 有先例) | 该值只有三个装配点持有,后端层 11 处构造点拿不到;为此给 `RedisLimiter`/`RedisBreaker` 增构造参数,是让后端层去持有"重投策略"——违反 P7(决策逻辑与状态存储分离),后端只该知道"我坏了",不该知道这在治理上意味着什么 |
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI。目前无任何下游表达过需要调它;真需要时下游可完全忽略 `exc.retry_after_s` 用自有退避 |
**选定**:`errors.py` 模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,作为构造默认值,docstring 写明理由——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),取一个保守固定值;下游若有自己的退避策略可忽略此值。本库对 scope 级异常硬编码语义值已有先例(`retry.py:206``no_sources``0.0`)。
它**不是环境配置项**,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 `pydantic-settings` + `.env` 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 `no_sources``0.0` 同性质。docstring 需显式写明这一点,避免后来者误加环境键。
### 3.3 `scope` 的三层来源
| 层 | 构造点数 | scope 来源 | 改动 |
|---|---|---|---|
| `backends/redis/limiter.py` | 6 | `self._scope`(`:170`) | 补 `scope=self._scope` |
| `backends/redis/breaker.py` | 5 | `self._scope`(`:291`) | 补 `scope=self._scope` |
| `middleware/breaker.py` `BreakerGate` | 5 | **需注入** | 构造函数增 `scope: str`,三处装配传 `self._scope` |
| `middleware/ratelimit.py` `QuotaGate` | 4 | **需注入** | 同上 |
包装器对后端自抛异常的 `except GovernanceBackendError: raise` 原样放行**保持不变**——后端层已填好 scope,重建实例只会制造"同一异常构造两次"的怪味且覆盖值相同。
### 3.4 "未知源"拆分为独立错误类
`backends/memory/limiter.py:92``backends/redis/limiter.py:198``_cfg()` 在源名不在配置字典中时抛 `GovernanceBackendError`。**这不是后端故障**,是限流后端拿到的源列表与治理循环的对不上——装配期缺陷,正常不可达。
若随整类归入"延期重投、不扣失败预算",配置写错的任务将**永远重投、永远不进死信**,运维永远收不到告警——正是本 issue 要修的 bug 的镜像。
新增 `SourceNotConfiguredError(PolyGatewayError)`,**有意不放在** `GatewayUnavailableError` 之下:下游默认按"任务的错"处置 → 扣失败预算 → 进死信 → 人能看见。这是缺陷该有的可见性。该类进 `__init__.py` 公共导出(下游可选择性识别,但不识别也能得到正确处置)。
### 3.5 message 保全
`GatewayUnavailableError.__init__` 会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点携带的诊断串(如 `限流后端 try_acquire 失败: {exc}`)是排障的主要线索,不可丢。方案是 `super().__init__()` 后覆写 `self.args = (message,)`,使 `str(exc)` 仍为原诊断串,而 `scope`/`reason`/`retry_after_s` 作为结构化字段并存。父类不动——它的 message 生成逻辑对 `CircuitOpenError`/`AllSourcesExhausted` 仍然正确。
## 4. 被否决的路线
| 路线 | 否决理由 |
|---|---|
| **B: 只补文档,类型树不动** | 正确性依赖每个下游都读到那句话。本库下游不止一个,且本 issue 本身就是"文档读不出来"引发的——同一个失效模式不能用同一种药治 |
| **C: 类型树不动,在 RetryMW 边界包成 `AllSourcesExhausted`** | 比 A 更具破坏性:下游现有 `except GovernanceBackendError` 会直接失效。加父类是扩大,换类型是破坏 |
| **D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译**(初评时倾向,已否决) | `backends/redis/limiter.py:133,151``RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**(失败只 warning 不冒泡)。原始 redis 异常穿透后该处接不住,会破坏这条既有降级行为;改为 `except Exception` 则违反 P5 |
## 5. 行为审计(既有行为逐条标注)
| 既有行为 | 出处 | 处置 |
|---|---|---|
| 限流/熔断后端不可用 → 报错而非放行(fail-closed) | 库铁律 | **保留**,一字不改 |
| 记账路径后端故障降级为 warning | `middleware/retry.py:404` `_record_quietly` | **保留**。仅闸门路径需要到达调用方 |
| permit `release`/`settle` 失败降级 warning | `redis/limiter.py:133,151` | **保留**(§4 路线 D 因此被否决) |
| 遥测对后端故障发 `emit_terminal_failure` | `middleware/telemetry.py:250` | **保留**,父子关系后由父类分支承接,行为不变 |
| `except GovernanceBackendError: raise` 原样放行 | 包装器 9 处 | **保留** |
| "未知源"抛 `GovernanceBackendError` | `memory/limiter.py:92``redis/limiter.py:198` | **替换**为 `SourceNotConfiguredError`(§3.4) |
| `str(exc)` 为诊断串 | 22 处 | **保留**(§3.5 显式保全) |
## 6. 非功能维度
| 维度 | 回答 |
|---|---|
| **并发与取消** | 不适用于新增并发路径。异常构造是纯同步无状态操作,不引入共享状态。`CancelledError` 穿透路径完全不受影响——本设计不新增任何 `except` 子句,`_record_quietly``except asyncio.CancelledError`(`:402`)先于 `except GovernanceBackendError`(`:404`)的顺序不动 |
| **降级方向** | 不变。fail-closed 是本类存在的理由,本设计只改"它被归入哪一类",不改"它是否被抛出" |
| **幂等与重复** | 异常类型变更不涉及幂等性。需注意的是下游行为改变:同一次后端故障从"扣失败预算"变为"延期重投",重投次数由下游队列策略决定——这正是期望的变更,已在 CHANGELOG 行为变更段声明 |
| **持久化与原子性** | 无持久化改动。遥测落库路径(`emit_terminal_failure`)的字段与调用时机均不变 |
## 7. 错误处理与测试策略
新失败面只有一个:`SourceNotConfiguredError`,它落在四分类之外。这**不违反** CLAUDE.md §4.2「一切失败必须落入四分类」——该铁律的论域是 **transport 层翻译的调用失败**(`ARCHITECTURE.md` §6.2 的翻译规则表逐条对应 HTTP 状态码与解析失败),而本库已有一整族异常合法地处在四分类之外:`GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` 都不是四分类之一,`ARCHITECTURE.md` §6.1 把它们单列一行,因为它们回答的是另一个问题——"整个 scope 还能不能用",而非"这一次调用怎么失败的"。
`SourceNotConfiguredError` 属于第三个论域:**装配缺陷**(配置与治理循环不一致,正常不可达)。四分类决定重试/换源/熔断,而装配缺陷根本不该进入治理循环去被"决定",它应当立刻失败并让人看见。将其塞进四分类中的任何一类都会赋予它一份不该有的治理语义(如 `RequestRejectedError` 会让下游以为请求本身有问题、去修请求)。§9 Q1 保留了"复用 `RequestRejectedError`"作为备选供人类权衡。
| 测试 | 位置 | 先失败后通过的证据 |
|---|---|---|
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_errors.py` | 改前 `pytest.raises(GatewayUnavailableError)` 必失败 |
| 三条泄漏路径(`try_acquire`/`try_enter`/`progress_age_s`)抛出的异常携带正确 `scope` 与非零 `retry_after_s` | `tests/unit/test_backpressure.py`**三条桩都需新增**(Codex 审计划时核出: `:176-186` 是记账侧 `record_success`/`record_failure`/`mark_progress` 的降级桩,不是闸门路径;`progress_age_s``:243-257` 覆盖包装行为、不验 scope) | 改前无 `scope` 属性,`AttributeError` |
| `str(exc)` 仍为原诊断串 | `tests/unit/test_errors.py` | 防 §3.5 回归 |
| 未知源抛 `SourceNotConfiguredError` 且**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`;内存版**当前无覆盖,需新增** | 改前抛 `GovernanceBackendError`,断言"不是 scope 级"必失败 |
| Redis 真实掉线时准入侧行为 | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 断言由 `GovernanceBackendError` 收紧为"是 `GatewayUnavailableError``reason == governance_backend_down`" |
## 8. 影响面清单
| 类别 | 内容 |
|---|---|
| **源码** | `errors.py`(新常量+新类+继承变更)、`backends/redis/limiter.py`(7)、`backends/redis/breaker.py`(5)、`backends/memory/limiter.py`(1)、`middleware/breaker.py`(6:构造函数+5 处)、`middleware/ratelimit.py`(5)、`middleware/retry.py`/`ocr.py`/`embedding.py`(各 1 行装配)、`__init__.py`(导出新类) |
| **测试** | `tests/unit/test_errors.py``test_backpressure.py``test_redis_key_layout.py``tests/integration/test_redis_cross_connection.py` |
| **文档** | `README.md` §"错误模型"增两列表 + `GovernanceBackendError` 行;`ARCHITECTURE.md` §6.1 回补该类并记录本次归位;`migrations/chsanalyzer.md` G1 条目补注;`CHANGELOG.md` 1.1.0;按 `docs-convention.md` §2 同步 Gitea Wiki |
| **版本** | **1.1.0**。有行为变更(下游对后端故障的处置路线改变)但无 API 破坏(加父类是扩大),按语义化版本走 minor |
| **下游** | CHSAnalyzer3 当前在 1.0.1。升级后 `except GatewayUnavailableError` 即覆盖后端故障,其现有 `except GovernanceBackendError`(若有)继续有效,无需改代码即可获得修复 |
### 8.1 执行顺序(单一事实源纪律)
`ARCHITECTURE.md` 是架构单一事实源,`SCOPE_REASONS` 新增值域与 `GovernanceBackendError` 的归位都与其 §6.1 现状冲突。因此 **§6.1 的修订必须先于或同批于代码实现落地**,不得"先改代码、事后补文档"。具体为:人类批准本设计后,`writing-plans` 的第一项任务即为修订 `ARCHITECTURE.md` §6.1(补 `GovernanceBackendError``SourceNotConfiguredError` 行、scope 级 reason 值域增 `governance_backend_down`、记录本次归位的理由与日期),与实现同一分支、同批提交。
## 9. 待人类确认的决策点
(编号用 Q 前缀,避免与 `ARCHITECTURE.md` 的架构决策 D1D14 混淆)
**三点均已由人类拍板(2026-08-06),全部采纳本文的选择:**
| # | 决策 | 裁定 | 被否决的备选及理由 |
|---|---|---|---|
| Q1 | "未知源"归到哪 | ✅ **拆为 `SourceNotConfiguredError`**,不在 `GatewayUnavailableError` 之下(§3.4) | ① 沿用 `GovernanceBackendError`——配置写错的任务将无限重投、永不进死信、无人发现;② 复用 `RequestRejectedError`——治理行为与选定方案**完全等价**,但名称误导:下游会去查 prompt 而非配置文件 |
| Q2 | `retry_after_s` 取值 | ✅ **常量 `5.0`**(§3.2) | 取 0 会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成风暴 |
| Q3 | 新类是否公共导出 | ✅ **导出**(进 `__init__.py`) | 不导出则下游无法给"配置写错"单独接告警,而导出无成本 |
## 10. 审批记录
| 阶段 | 状态 |
|---|---|
| Claude 自审 | 已完成(全部结论对应本会话内 grep/read 输出;§3.5 的 `self.args` 保全机制经 conda 环境实跑验证) |
| Codex 独立审 | 已完成(2026-08-06),4 条意见逐条核验见下 |
| 人类审批 | ✅ **已批准(2026-08-06)**。方向 A′ 于设计前即由人类选定;Q1–Q3 三个决策点逐条拍板,全部采纳本文选择(见 §9)。可进入 `writing-plans` |
### 10.1 Codex 意见的核验结果
| 意见 | 判定 | 处置 |
|---|---|---|
| ARCHITECTURE §6.1 未同步前实施违反单一事实源(判为阻塞) | **实质成立**,但性质是执行顺序而非设计缺陷——§8 本已把 §6.1 回补列入影响面 | 新增 §8.1 明确"架构文档修订先于/同批于实现" |
| §6.1 错误分类表未承认 `GovernanceBackendError`(判为阻塞) | **与上条同源**,且 §1.1 已自陈此为根因 | 同上,由 §8.1 覆盖 |
| `SourceNotConfiguredError` 落在四分类外违反 §4.2 铁律(判为阻塞) | **部分成立**:铁律论域被误读——`GatewayUnavailableError` 族本就合法处在四分类之外(§6.1 单列一行)。但原文表述确会引起该疑虑 | §7 补写三个论域的划分论证;§9 Q1 增列"复用 `RequestRejectedError`"备选交人类权衡 |
| 硬编码常量与 §4.5 存在张力(建议性) | **成立** | §3.2 补写"非环境配置项"及 docstring 要求 |
| Q 编号与架构 D1–D14 混淆(建议性) | **成立** | §9 决策点编号由 `D` 改为 `Q` |
@@ -0,0 +1,44 @@
---
type: design
node_id: design:governance-backend-error
title: "治理后端故障归位为 scope 级不可用(Issue #7)"
date: 2026-08-06
---
# 治理后端故障归位为 scope 级不可用(Issue #7)
全文见 `2026-08-06-governance-backend-error-design.md`。来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败)。**状态: 已批准(2026-08-06,人类逐条拍板 Q1/Q2/Q3),待 `writing-plans`。**
问题: 限流/熔断状态后端故障时库 fail-closed,一个请求都发不出去——语义上就是 scope 级不可用,但 `GovernanceBackendError``PolyGatewayError` 的**直接子类**,只写 `except GatewayUnavailableError` 的调用方接不住,于是 Redis 抖一下,积压任务一批批消耗业务失败预算进死信,而那是运维重启就好的故障。
## 选定方案
| 决策 | 选定 | 关键理由 |
|---|---|---|
| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS``governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:250` 一处捕父类且已并列写两者,**零回归** |
| B `retry_after_s` | 模块常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,非环境配置项 | 后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻);取 0 会让积压任务零延迟批量重投,把一次故障放大成风暴 |
| C scope 来源 | 后端层用 `self._scope`;`QuotaGate`/`BreakerGate` 构造函数注入,三处装配(`retry.py`/`ocr.py`/`embedding.py`)各传一行 | 两个包装器是后端异常的唯一入口,注入点收敛;三处装配本就持有 `self._scope` |
| D 未知源拆分 | `_cfg()` 的 2 处改抛新增的 `SourceNotConfiguredError`,**有意不放在** `GatewayUnavailableError` 之下 | 那是装配缺陷不是后端故障;随整类归入"可重投"会让配置写错的任务永远重投、永不进死信——本 issue 要修的 bug 的镜像 |
| E message 保全 | `super().__init__()` 后覆写 `self.args = (message,)` | 父类会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点的诊断串是排障主线索。机制已实跑验证 |
## 被否决的备选
| 备选 | 否决原因 |
|---|---|
| B(issue 原议): 只补文档,类型树不动 | 正确性依赖每个下游都读到那句话;本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治 |
| C: 在 RetryMW 边界包成 `AllSourcesExhausted` | 比选定方案更具破坏性——下游现有 `except GovernanceBackendError` 直接失效 |
| D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译 | 初评时倾向。`redis/limiter.py:133,151``RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**,穿透后接不住会破坏该既有行为;改 `except Exception` 则违反 P5 |
| `retry_after_s` 复用 `BackpressureConfig.poll_interval_s` | 该值只有三个装配点持有,为此给后端加构造参数等于让状态存储层持有重投策略,违反 P7 |
| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI;无下游表达过需要,真需要时下游可忽略该字段用自有退避 |
## 对 issue 前提的四处修正
泄漏路径是**三条**不是两条(`retry.py:216``progress_age_s()` 同样在 catch 之外);构造点 **22 处**;其中 2 处语义完全不同(未知源);`retry_after_s=0` 语义通但工程不通。
根因记录: `ARCHITECTURE.md` §6.1 错误分类表里 `GovernanceBackendError` **一次都没出现**——它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来没有位置,README 的遗漏是这个遗漏的下游后果。
## 独立审查修正(2026-08-06, Codex)
4 条意见逐条核验: 两条"架构文档未同步"实质成立但性质是执行顺序 → 新增 §8.1 钉死"`ARCHITECTURE.md` §6.1 修订先于/同批于实现";"新错误类违反四分类铁律"**部分成立**——铁律论域被误读(`GatewayUnavailableError` 族本就合法处在四分类之外),但原表述确会引起疑虑 → §7 补写三论域划分论证,并把"复用 `RequestRejectedError`"增列为待人类权衡的备选;两条建议性意见(常量非配置项的说明、决策编号 `D``Q` 防与架构 D1–D14 混淆)已采纳。
相关: [[m2-distributed]]、[[m1-core-design]]、[[m25-resilience]]
@@ -0,0 +1,195 @@
---
type: finding
node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens
title: "推理开关与 reasoning_tokens: 供应商实测与业界做法"
date: 2026-08-02
---
# 推理开关与 reasoning_tokens:供应商实测与业界做法
> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研
> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。
> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
## 1. 实验环境与方法
| 项 | 值 |
|---|---|
| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`OpenAI 兼容) |
| 参数 | `temperature=0``max_tokens=800`、非流式为主,流式单独验证 |
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) |
| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
## 2. MiniMax:真开关是 `reasoning_effort`
### 2.1 M3 参数矩阵(非流式)
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|---|---|---|---|---|
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 |
| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 |
| `reasoning_effort=low` | **207** | 98 | 93 | 推理 |
| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 |
| `reasoning_effort=high` | **207** | 158 | 142 | 推理 |
| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** |
| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** |
| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** |
| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** |
`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
### 2.2 `none` 是被识别的真值,不是被当非法值丢弃
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。
反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。
对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。
### 2.3 M2.7 / M2.5 的推理关不掉
三种参数形态各 3 次,`completion_tokens` 全部落在推理区间:
| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` |
|---|---|---|---|---|
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
| MiniMax-M2.5 | 273//256 | 363/353/264 | 286/278/320 | 278/228/259 |
真关闭应为 510"23 12" 两个数字),实测无一接近。
**三个独立外部来源与实测完全吻合**
| 来源 | M3 | M2.7 / M2.5 |
|---|---|---|
| OpenRouter `/api/v1/models``reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** |
| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) |
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
### 2.4 M3 的稳定性
同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。
### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮)
初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪:
| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` |
|---|---|---|
| 关闭(`reasoning_effort=none` | 4 **46** | 15/15 轮为 `None` |
| 开启(`medium` | **13** 186 | 15/15 轮 > 0 |
**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。
`reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。
另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。
## 3. qwen / deepseek:现有 profile 正确
| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile |
|---|---|---|---|---|
| qwen3.7-plus | ✅ 关闭(compl 5 | ✅ 关闭 | ✅ 关闭 | `enable_thinking`**正确** |
| deepseek-v4-pro | ❌ 无效(仍推理 198 | ✅ 关闭(compl 3 | ✅ 关闭 | `thinking:{type}`**正确** |
两点附带事实:
- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。
- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `<think>` 标签。无害,但属于死代码。
- **非流式没有 400**DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
## 4. new-api 中转的三个行为(会污染观测)
这一节对任何经中转做实测的场景都适用,值得单独记住。
**a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
**(b)静默丢弃未知键。** 默认路径是 struct round-trip`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。
**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象:
| 现象 | 解释 |
|---|---|
| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens``prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd |
**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。
**对库的直接影响**`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。
## 5. 业界如何建模"同一语义、形态因模型而异"
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
### 5.1 核心共识:形态按 provider,能力按 model
| 概念 | 变化频率 | 应归属层次 |
|---|---|---|
| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 |
| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** |
注册单位的分布很能说明问题:LiteLLM2986 条目)、models.dev5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
### 5.2 失败语义的四种谱系
| 语义 | 代表 | 适用前提 |
|---|---|---|
| 默认报错 + 可配置降级开关 | LiteLLM`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 |
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]` | 调用方愿意读 warning |
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 |
| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** |
**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
### 5.3 表会过期,这是公理
LiteLLM 有过真实事故(issue #27351`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴:
- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。**
### 5.4 「布尔开关 → 多档旋钮」无语义共识
| 系统 | effort → 预算的换算 |
|---|---|
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20% |
| Helicone | 一律 `max_tokens/2`,完全不看档位 |
| LangChain | 明确不保证跨 provider 可比 |
**唯一对齐的是"关"**`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。
**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
### 5.5 Vercel AI SDK 的一处设计值得单记
它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。
## 6. 附带发现(不属本次范围,建议另立 issue)
**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。
**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
## 7. 未能证实
1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。
2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。
3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。
4. **能力表条目对非本次实测模型的正确性**qwen / deepseek 只测了各一个型号,同系其他型号未验证。
## 8. 对后续开发的指导
1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。
2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。
4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。
5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。
7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。
+31
View File
@@ -130,6 +130,16 @@
"id": "plan:sampling-params-plan",
"label": "采样参数透传实现计划(issue #4)",
"type": "plan"
},
{
"id": "design:governance-backend-error",
"label": "治理后端故障归位为 scope 级不可用(Issue #7)",
"type": "design"
},
{
"id": "plan:governance-backend-error",
"label": "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)",
"type": "plan"
}
],
"links": [
@@ -223,6 +233,27 @@
"relation": "implements",
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
"added": "2026-07-31T16:59:35.657367+00:00"
},
{
"source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens",
"target": "design:2026-08-02-thinking-capability-design",
"relation": "supports",
"evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据",
"added": "2026-08-02T09:38:57.033054+00:00"
},
{
"source": "plan:2026-08-02-thinking-capability",
"target": "design:2026-08-02-thinking-capability-design",
"relation": "implements",
"evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准",
"added": "2026-08-02T09:49:48.126539+00:00"
},
{
"source": "plan:governance-backend-error",
"target": "design:governance-backend-error",
"relation": "implements",
"evidence": "T1-T5 逐任务实现设计 §3 的五项决策与 §8 影响面清单",
"added": "2026-08-06T08:08:51.865565+00:00"
}
]
}
+12 -5
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-08-01 01:58 UTC
> 自动生成,更新时间:2026-08-06 08:11 UTC
## design (20)
## design (23)
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
@@ -13,6 +13,7 @@
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -22,9 +23,11 @@
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (11)
## finding (12)
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
@@ -36,8 +39,9 @@
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (16)
## plan (19)
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -46,6 +50,7 @@
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
@@ -53,10 +58,12 @@
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## schema (1)
- [表结构: llm_calls(遥测 21 字段)](schemas/llm-calls.md) `schema:llm-calls`
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+17
View File
@@ -73,3 +73,20 @@
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面
- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design
- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens)
- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design)
- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
- [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design
- [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability)
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面
- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测)
- [2026-08-06 06:37 UTC] 新增 design: 治理后端故障归位为 scope 级不可用(Issue #7) (design:governance-backend-error)
- [2026-08-06 06:38 UTC] 重建索引: 55 篇页面
- [2026-08-06 08:08 UTC] 新增 plan: 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7) (plan:governance-backend-error)
- [2026-08-06 08:08 UTC] 新增边: plan:governance-backend-error --implements--> design:governance-backend-error
- [2026-08-06 08:08 UTC] 重建索引: 57 篇页面
- [2026-08-06 08:11 UTC] 重建索引: 57 篇页面
+1 -1
View File
@@ -181,7 +181,7 @@ stack = ExtractionProviderStack(
| R4 | 六道闸+契约 5 条、服务器时钟窗口、settle 落 acquire 窗口、transient 按 est 保守结算 | M2 | §7.3 大体覆盖 |
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常。**2026-08-06 补(issue #7,库 1.1.0)**: 治理后端故障(`GovernanceBackendError`,Redis 挂等 fail-closed 情形)此前**不在**该族内,`except GatewayUnavailableError` 接不住,会落进 `_TERMINAL` 兜底而消耗业务失败预算;现已归入该族(`reason=governance_backend_down`,`retry_after_s` 默认 5.0),tracking.py 一条 except 即覆盖完整,**无需为它单列分支**。同批新增的 `SourceNotConfiguredError`(源名与配置不匹配的装配缺陷)**有意在族外**,应当落进 `_TERMINAL` 让配置错误浮出水面 | M2 | 已闭 |
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
@@ -0,0 +1,276 @@
---
type: plan
node_id: plan:2026-08-02-thinking-capability
title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)"
date: 2026-08-02
---
# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6
**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。
**方案概述**`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3`_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。
**涉及技术**Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。
**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`
**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` |
| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` |
| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens``_build_payload` 接入 `resolve_thinking`;收 `capabilities` |
| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` |
| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 |
| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` |
| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1L9 |
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
**任务顺序不可调换**T1T3 先把 `reasoning_tokens` 打通(#6#5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。
## 关键接口(跨任务消费,此处给出实际代码)
`providers.py` 新增:
```python
@dataclass(frozen=True)
class ThinkingCapability:
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
can_disable: bool
evidence: str
def get_capability(
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
) -> ThinkingCapability | None:
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。"""
```
```python
def register_capability(
model: str,
capability: ThinkingCapability,
*,
base: Mapping[str, ThinkingCapability] | None = None,
) -> dict[str, ThinkingCapability]:
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
enable_thinking: bool | None,
*,
model: str,
) -> Mapping[str, Any]:
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。
model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性,
而 capability 为 None(未登记)时无从从别处取得模型名。
"""
```
`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**):
| 步 | 条件 | 行为 |
|---|---|---|
| 1 | `enable_thinking is None` | 返回 `{}`(不干预) |
| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` |
| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) |
| 4 | `enable_thinking is False``capability.can_disable is False` | `ValueError`:该模型无法关闭推理 |
| 5 | 其余 | 返回 `slot` |
第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。
`transports/openai_compat.py` 新增:
```python
def _coerce_reasoning_tokens(usage: Any) -> int | None:
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""
```
## 任务清单
### T1 — `reasoning_tokens` 进入类型与采集路径
- [ ] **文件**:改 `src/polygateway/types.py``src/polygateway/transports/openai_compat.py``src/polygateway/middleware/retry.py`;改测 `tests/unit/test_types.py``tests/unit/test_openai_compat.py``tests/unit/test_retry.py`
**行为**`LLMResponse``TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens``openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None``completion_tokens_details``dict` 返回 `None``bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None``0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。
**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。
**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7``reasoning_tokens == 7`;该键为 `0``0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None``missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`
**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。
### T2 — 遥测端口 21 → 22 字段与两后端落库
- [ ] **文件**:改 `src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`;改测 `tests/unit/test_ports.py``tests/unit/test_telemetry.py``tests/integration/test_postgres_telemetry.py`
**行为**`record_llm_call``sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`sqlite/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py``_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt``response.reasoning_tokens if response else None``emit_cache_hit` 原样回放,`emit_terminal_failure``None`
**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0``NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。
**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。
### T3 — 提交点:issue #6 完整可用
- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。
**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。
### T4 — 形态层放宽与 profile 修正
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
**行为**`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25``:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。
`minimax``thinking_on={"reasoning_effort": "medium"}``thinking_off={"reasoning_effort": "none"}``openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。
**验收**`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}``get_provider("openai").thinking_on is None`qwen / deepseek 两档与改动前逐字相同。
**测试证据**`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
### T5 — 能力层与 `resolve_thinking`
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
**行为**:按「关键接口」一节的签名实现 `ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider``providers.py:84-90`)同形。
`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量:
| 键 | `can_disable` | `evidence` 要点 |
|---|---|---|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10`reasoning_effort=none` 稳定关闭 |
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter 登记 `mandatory:true` |
| `MiniMax-M2.5` | `False` | 同上 |
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
| `deepseek-v4-pro` | `True` | 实测 `thinking:{"type":"disabled"}` 关闭 |
**验收**`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning**loguru 不经标准 loggingpytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7``ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider``extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`
**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
### T6 — transport 接入与请求期兜底
- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py`
**行为**`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities``_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)``payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。
**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`
**验收**`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"``False``"none"``None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`
**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。
### T7 — 装配守卫、参数贯通与缓存指纹
- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py`
**行为**(三件事,同一文件):
其一,`from_settings``from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport``from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider``client.py:248``openai_compat.py:313` 双点调用的既有形态一致。
其二,`build_model_fingerprint``client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**
```python
def _fingerprint_mark(s: SourceConfig) -> str:
parts: list[Any] = [s.model, dict(s.extra_body)]
if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变
parts.append(s.enable_thinking)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
```
筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`
其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7``GatewaySettings``from_settings``ValueError``provider=openai` + 任意非 `None``enable_thinking``ValueError`
**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。
**测试证据**:先写三条断言 → 失败 → 实现 → 通过。
**验证**`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。
### T8 — 真实 API e2e 矩阵
- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py`
**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**:另加项目既有的 `slow` 标记,靠 `pyproject.toml``addopts = "-m 'not slow'"` 把本组挡在 `make ci` 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 `network_error` 耗尽源)。合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。
**源映射**L1L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*``MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*``{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。
覆盖矩阵(轮数经环境变量可调,默认值如下):
| # | 场景 | 源 | 轮数 | 判据 |
|---|---|---|---|---|
| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) |
| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 |
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` |
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2`stream=True` 是库的默认主路径) |
| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` |
| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` |
| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 |
| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 |
**三条必须遵守的测试纪律**
其一,**判别量只能是 `reasoning_tokens`**。(执行时按 e2e 实测修正:本条初稿写的是「主判据用 `completion_tokens`」,被数据推翻——两档的输出长度分布**重叠**,关闭档实测最高 46、开启档最低 13,按长度阈值判两个方向都会误判。)`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路(findings §4c、§2.5)。
其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到,关闭方向不受影响。
其四,**必须有不依赖输出侧噪声的锚点**:L2b 比较两档的 `prompt_tokens`(相对比较,无魔数),L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态,不排除它,关闭方向的证据就只到「未回归」,够不到「已生效」。
其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。
**报告内容**`tests/outputs/e2e/test_thinking_live_<ts>.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。
**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`
**验证**`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。
### T9 — 文档同步与收尾
- [ ] **文件**:改 `CHANGELOG.md``research-wiki/schemas/llm-calls.md`
**行为**CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。
Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API``LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。
**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。
**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。
## 完成后的独立验证
`verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。
### T10 — 同步结论给 dissect
- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。
**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。
## 需要同步给下游的结论
`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。
@@ -0,0 +1,263 @@
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
- **设计**: `research-wiki/designs/2026-08-06-governance-backend-error-design.md`(已批准 2026-08-06,Q1/Q2/Q3 逐条拍板)
- **分支**: `feat/issue-7-governance-backend-error`
- **目标**: 让"限流/熔断后端故障"在类型上落入 `GatewayUnavailableError`,使调用方一条 `except` 覆盖完整;同时把混在同一类里的装配缺陷拆出去,避免配置写错的任务永远重投。
- **方案概述**: `GovernanceBackendError` 改继承 `GatewayUnavailableError`(新 reason `governance_backend_down`,`retry_after_s` 默认 5.0);两处"未知源"改抛新增的 `SourceNotConfiguredError`(**不**在 scope 级家族内);`scope` 由后端层 `self._scope` 与两个 gate 包装器注入。
- **涉及技术**: Python 3.11+,pytest(含真实 Redis 的 integration),radon/ruff 门禁。
## 保真校验(适用)
本计划触及 ARCHITECTURE.md §1.4 索引的移植蓝本:错误分类(`reference/CHSAnalyzer/app/domain/errors.py`)与限流/熔断(`reference/CHSAnalyzer/app/coordination/`)。
本次**有意变更**的语义只有一条,已在设计 §3.1 声明:`GovernanceBackendError` 的类型归属(CHS 的 `LimiterError` 是独立异常,本库将其提升为 scope 级不可用的一员)。除此之外,下列承自 CHS 的语义**不得被顺带改动**,每个任务完成前逐条自查:
| 不得改动 | 出处 |
|---|---|
| `retry_after_s` 非可选、`0 = 可立即重试` | `errors.py:74-78` |
| `SCOPE_REASONS` 既有 5 值与 `SOURCE_REASONS` 既有 7 值 | `errors.py:7-20` |
| fail-closed 降级方向(限流/熔断后端挂 → 报错而非放行) | 库铁律 |
| 记账路径降级为 warning、闸门路径上抛的分工 | `middleware/retry.py:404` |
| `RedisPermit.release/settle` 的释放侧降级 | `backends/redis/limiter.py:133,151` |
## 文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
| `research-wiki/ARCHITECTURE.md` | 架构单一事实源 §6.1 错误分类表 | 修改(**必须先行**,见设计 §8.1) |
| `src/polygateway/errors.py` | 错误类型树内核 | 修改: 新常量、新 reason、新类、继承变更 |
| `src/polygateway/__init__.py` | 公共 API 面 | 修改: 导出新类 |
| `src/polygateway/backends/redis/limiter.py` | Redis 限流后端 | 修改: 6 处补 scope、1 处换新类 |
| `src/polygateway/backends/redis/breaker.py` | Redis 熔断后端 | 修改: 5 处补 scope |
| `src/polygateway/backends/memory/limiter.py` | 内存限流后端 | 修改: 1 处换新类 |
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate` 包装器 | 修改: 构造增 scope、4 处补 scope |
| `src/polygateway/middleware/breaker.py` | `BreakerGate` 包装器 | 修改: 构造增 scope、5 处补 scope |
| `src/polygateway/middleware/retry.py` / `ocr.py` / `embedding.py` | 三处 gate 装配 | 修改: 各 2 行传 scope |
| `tests/unit/test_errors.py` | 错误类型契约 | 修改 |
| `tests/unit/test_backpressure.py` | 后端故障传播 | 修改 |
| `tests/unit/test_redis_key_layout.py` | 未知源行为 | 修改 |
| `tests/integration/test_redis_cross_connection.py` | 真实 Redis 掉线 | 修改 |
| `README.md` / `research-wiki/migrations/chsanalyzer.md` / `CHANGELOG.md` / `pyproject.toml` | 文档与版本 | 修改 |
## 关键接口(跨任务消费,此处写死)
`errors.py` 新增与变更部分:
```python
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
"""治理后端故障的建议重投间隔(秒)。
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期
时刻),故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让
积压任务零延迟同时冲击已挂掉的后端(issue #7 §3.2)。
"""
class SourceNotConfiguredError(PolyGatewayError):
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是
"配置写错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置
错误的任务永远重投、永不告警(issue #7 §3.4)。
"""
class GovernanceBackendError(GatewayUnavailableError):
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
继承 `GatewayUnavailableError`: fail-closed 时一个请求都发不出去,语义
上即 scope 级不可用,调用方一条 except 即可覆盖(issue #7)。
"""
def __init__(
self,
message: str,
*,
scope: str,
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
source_name: str | None = None,
) -> None:
super().__init__(
scope=scope,
reason="governance_backend_down",
retry_after_s=retry_after_s,
source_name=source_name,
)
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
# 携带的诊断串是排障主线索,必须保住(设计 §3.5,机制已实跑验证)
self.args = (message,)
```
两个 gate 包装器的构造签名(`scope` 为 keyword-only 必填):
```python
class QuotaGate:
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
self._limiter = limiter
self._scope = scope
class BreakerGate:
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
self._gate = gate
self._scope = scope
```
---
## 任务清单
### - [ ] T1: ARCHITECTURE §6.1 回补(必须先行)
**文件**: `research-wiki/ARCHITECTURE.md`(§6.1,约 372-380 行)
**行为**: 在错误分类表补两行——`GovernanceBackendError`(scope 级不可用,reason 恒为 `governance_backend_down`)与 `SourceNotConfiguredError`(装配缺陷,不重试不换源,消耗失败预算);scope 级 `reason` 值域由 5 值扩为 6 值,增 `governance_backend_down`。同时记录本次归位的理由与日期,并说明根因(该类是 M2 引入分布式后端时新增,当时未回补本表)。
**为什么先行**: `ARCHITECTURE.md` 是单一事实源,新 reason 值域与其现状冲突;先改代码后补文档等于让实现与事实源脱节(设计 §8.1)。
**验收**: §6.1 表格含上述两行;reason 值域文字与 `errors.py` 将要写入的 `SCOPE_REASONS` 逐字一致。
**测试要求**: 纯文档,无测试证据要求。
**验证**: `grep -n "governance_backend_down\|SourceNotConfiguredError" research-wiki/ARCHITECTURE.md` → 至少各 1 处命中。
**提交**: `docs: admit governance backend failures into the scope-level error model`
---
### - [ ] T2: errors.py 纯增量(新常量、新 reason、新类)+ 导出
**文件**: 改 `src/polygateway/errors.py``src/polygateway/__init__.py`;改 `tests/unit/test_errors.py`
**行为**:
1. 加模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`(docstring 逐字见上文"关键接口");
2. `SCOPE_REASONS``"governance_backend_down"`;
3. 新增 `SourceNotConfiguredError(PolyGatewayError)`(定义逐字见上文);
4. `__init__.py` 的 import 块与 `__all__` 各增 `SourceNotConfiguredError`(`__all__` 保持字母序: `SourceDeadError`**`SourceNotConfiguredError`** → `TransientError`,即插在 `SourceDeadError` **之后**)。
**本任务不动 `GovernanceBackendError`**——它是纯增量,不破坏任何既有调用点,可独立提交且全套件保持通过。
**测试要求(先失败后通过)**:
- 新增用例断言 `SourceNotConfiguredError` **不是** `GatewayUnavailableError` 的子类,且是 `PolyGatewayError` 的子类。改前该类不存在 → `ImportError`;改后 PASS。
- 新增用例断言 `"governance_backend_down" in SCOPE_REASONS`,且 `GatewayUnavailableError(scope="llm", reason="governance_backend_down", retry_after_s=0.0)` 可构造。改前 `reason` 校验抛 `ValueError` → 用例失败;改后 PASS。
- 新增用例断言 `from polygateway import SourceNotConfiguredError` 可用。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS;`conda run -n PolyGateway pytest tests/ -q` → 与改动前同样全绿(纯增量不应影响任何既有用例)。
**提交**: `feat: add SourceNotConfiguredError and the governance backend reason`
---
### - [ ] T3: `GovernanceBackendError` 归位 + 22 处构造点 + scope 注入(原子)
**文件**: 改 `src/polygateway/errors.py``backends/redis/limiter.py``backends/redis/breaker.py``backends/memory/limiter.py``middleware/ratelimit.py``middleware/breaker.py``middleware/retry.py``ocr.py``embedding.py`;改 `tests/unit/test_errors.py``tests/unit/test_backpressure.py``tests/unit/test_redis_key_layout.py``tests/integration/test_redis_cross_connection.py`
**为什么必须原子**: `scope` 是必填 keyword,继承变更与全部构造点若分批提交,中间状态会 `TypeError`,门禁跑不过。
**行为**:
1. `errors.py`: `GovernanceBackendError` 改继承 `GatewayUnavailableError` 并覆写 `__init__`(逐字见上文"关键接口")。
2. **两处未知源改抛新类**(设计 §3.4,Q1 已拍板):
| 位置 | 改为 |
|---|---|
| `backends/redis/limiter.py:198` | `raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")` |
| `backends/memory/limiter.py:92` | 同上 |
3. **后端层 11 处补 `scope=self._scope`**(该属性已存在: redis limiter `:170`、redis breaker `:291`、memory limiter 同名字段):
- `backends/redis/limiter.py``:250 / :268 / :275 / :286 / :298 / :305`(6 处)
- `backends/redis/breaker.py``:370 / :388 / :410 / :422 / :432`(5 处)
4. **两个 gate 包装器**: 构造函数改为上文"关键接口"的签名;`QuotaGate` 4 处(`ratelimit.py:30/38/46/54`)与 `BreakerGate` 5 处(`breaker.py:26/36/46/54/62`)的 `raise``scope=self._scope`
- 各方法开头的 `except GovernanceBackendError: raise` **保持不变**(后端层已填好 scope,重建实例只会重复构造,设计 §3.3)。
5. **三处装配各传 scope**(三处的 `self._scope` 均已在装配前赋值,无需调整顺序):
| 文件 | 行 | 改为 |
|---|---|---|
| `middleware/retry.py` | 186-187 | `QuotaGate(limiter, scope=self._scope)` / `BreakerGate(gate, scope=self._scope)` |
| `ocr.py` | 122-123 | 同款 |
| `embedding.py` | 123-124 | 同款 |
**测试要求(先失败后通过,逐条对应)**:
| 用例 | 文件 | 改前为何失败 |
|---|---|---|
| `GovernanceBackendError` 可被 `except GatewayUnavailableError` 接住,且 `reason == "governance_backend_down"``retry_after_s == 5.0` | `tests/unit/test_errors.py` | 改前非其子类,`pytest.raises(GatewayUnavailableError)` 不匹配 |
| `str(exc)` 仍为构造时的诊断串(防 §3.5 回归) | `tests/unit/test_errors.py` | 改前无该风险但改后若漏写 `self.args` 即失败,是回归护栏 |
| 三条泄漏路径(`try_acquire` / `try_enter` / `progress_age_s`)抛出的异常带正确 `scope`、且可被 `except GatewayUnavailableError` 接住 | `tests/unit/test_backpressure.py`**三条都要新增桩**。现状: `progress_age_s` 只有 `TestQuotaGateProgressAge`(`:243-257`)覆盖包装行为、不验 scope;`try_acquire`(`QuotaGate`)与 `try_enter`(`BreakerGate`)**完全无桩** | 改前异常无 `scope` 属性 → `AttributeError`;两条新路径改前无覆盖 |
| 未知源抛 `SourceNotConfiguredError`,且断言它**不是** `GatewayUnavailableError` | 改 `tests/unit/test_redis_key_layout.py:70-74`(`test_unknown_source_rejected`,现断言 `GovernanceBackendError`);内存版**当前无对应用例,需新增**一条同款(`backends/memory/limiter.py:92``_cfg("nope")`) | 改前 redis 版类型断言失败;内存版改前无覆盖(该分支从未被测过) |
| Redis 真实掉线时准入侧抛 scope 级异常且 `reason == "governance_backend_down"` | `tests/integration/test_redis_cross_connection.py:228-245`(真实 Redis,不 mock) | 改前无 `reason` 属性 |
**必须同批更新的既有测试构造点**(新签名为 keyword-only 必填,漏改即 `TypeError: missing required keyword-only argument`,门禁直接红):
| 位置 | 现状 | 改为 |
|---|---|---|
| `tests/unit/test_backpressure.py:176 / :181 / :186` | `raise GovernanceBackendError("redis 抖动")` | 补 `scope=`(任意测试 scope,如 `"llm"`) |
| `tests/unit/test_errors.py:89` | `exc = GovernanceBackendError("redis down")` | 同上;该用例现断言它**不属于**可重试分类,须一并改为断言它**是** `GatewayUnavailableError` |
| `tests/unit/test_backpressure.py:255 / :257` | `QuotaGate(_L())` / `QuotaGate(_Broken())` | `QuotaGate(_L(), scope="llm")` 等 |
**保真校验检查点**: 提交前对照上文"保真校验"五条逐条自查,确认无一被顺带改动。特别核对 `RedisPermit.release/settle`(`redis/limiter.py:133,151`)的 `except GovernanceBackendError` 仍能接住释放侧失败——该处是设计 §4 否决"让原始异常穿透"路线的直接原因。
**验证**:
- `conda run -n PolyGateway pytest tests/unit tests/contracts -v` → 全 PASS
- `conda run -n PolyGateway pytest tests/integration -v` → 全 PASS(需真实 Redis)
- `conda run -n PolyGateway pytest tests/ -q``0 failed`
- `conda run -n PolyGateway radon cc src -n C -s` → 无输出
- `make lint` → import-linter 契约全绿(本次不新增跨层依赖,应无变化)
**提交**: `fix: reparent governance backend failures under GatewayUnavailableError (issue #7)`
---
### - [ ] T4: 公开错误面文档(issue #7 第二诉求)
**文件**: 改 `README.md`(§"错误模型(四分类)",约 114-125 行)、`research-wiki/migrations/chsanalyzer.md`
**行为**:
1. README 增一张两列表,明确区分**会到达调用方**与**库内吸收**:
| 会到达调用方 | 库内吸收 |
|---|---|
| `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError`) | `TransientError` |
| `RequestRejectedError` | `SourceDeadError` |
| `ResultInvalidError` | |
| `SourceNotConfiguredError` | |
2. 在该表下补一句说明: `TransientError` / `SourceDeadError` 的 docstring 描述的是**库内治理行为**,它们被 `middleware/retry.py:365` 接住并在预算耗尽时包成 `AllSourcesExhausted`,**不会**到达调用方——issue #7 记载下游曾据此写错整段设计文档。
3. `migrations/chsanalyzer.md` 的 G1 条目补注:后端故障现已并入 `GatewayUnavailableError`,项目侧 `except GatewayUnavailableError` 一条即覆盖完整,无需为 `GovernanceBackendError` 单列分支。
**验收**: 调用方仅读 README 即可判断该 catch 什么,无需读 `middleware/retry.py`
**测试要求**: 纯文档,无测试证据要求。
**验证**: `grep -n "库内吸收" README.md` → 命中。
**提交**: `docs: publish which errors reach callers and which the library absorbs`
---
### - [ ] T5: 版本 1.1.0 + CHANGELOG + Wiki 同步
**文件**: 改 `pyproject.toml`(version)、`src/polygateway/__init__.py`(`__version__`)、`CHANGELOG.md`;按 `research-wiki/docs-convention.md` §2 同步 Gitea Wiki
**行为**: 版本 `1.0.6``1.1.0`(有行为变更但无 API 破坏:加父类是扩大)。CHANGELOG 需写明:
- **行为变更**: 后端故障从"落入调用方兜底分支"变为"被 `except GatewayUnavailableError` 捕获";下游据此把它按"延期重投、不消耗失败预算"处置,这正是修复目标,但**处置路线确实变了**,升级前须确认下游的兜底分支没有依赖它。
- **新增**: `SourceNotConfiguredError`(公共导出)、`GOVERNANCE_BACKEND_RETRY_AFTER_S`、scope 级 reason `governance_backend_down`
- **下游请读**: `GovernanceBackendError` 现携带 `scope` / `reason` / `retry_after_s`(默认 5.0)/`per_source_reasons`;`str(exc)` 仍是原诊断串,结构化字段并存。配置写错(源名不匹配)现在抛 `SourceNotConfiguredError` 而非 `GovernanceBackendError`,它**不**属于可重投家族——这是有意的,目的是让装配缺陷进死信而不是永远重投。
**验收**: 版本三处一致(`pyproject.toml` / `__init__.py` / CHANGELOG 标题);CLAUDE.md §6 要求"版本 bump 提交不得裸发",故本任务必须与 wiki 同步同批。
**测试要求**: 无行为变更,`pytest tests/ -q` 保持全绿即可。
**验证**: `grep -n "1.1.0" pyproject.toml src/polygateway/__init__.py CHANGELOG.md` → 三处命中。
**提交**: `chore: release 1.1.0`
---
## 完成后
按 CLAUDE.md §3 Phase 2,合并前须派**全新上下文**的 verifier subagent 做独立验证(`verification-before-completion`),并按新规则**前台运行**。随后走 `finishing-a-development-branch` 决定合并方式,并在 Gitea 关闭 issue #7
@@ -0,0 +1,37 @@
---
type: plan
node_id: plan:governance-backend-error
title: "实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)"
date: 2026-08-06
---
# 实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)
全文见 `2026-08-06-governance-backend-error-plan.md`。实现设计 [[governance-backend-error]](已批准 2026-08-06)。
## 五个任务
| # | 任务 | 关键约束 |
|---|---|---|
| T1 | `ARCHITECTURE.md` §6.1 回补两行 + reason 值域扩为 6 值 | **必须先行**——单一事实源纪律,先改代码后补文档等于让实现与事实源脱节(设计 §8.1) |
| T2 | `errors.py` 纯增量: 新常量、新 reason、`SourceNotConfiguredError` + 顶层导出 | 刻意不动 `GovernanceBackendError`,故全套件保持通过,可独立提交 |
| T3 | `GovernanceBackendError` 归位 + 22 处构造点 + gate scope 注入 + 全部受影响测试 | **必须原子**: `scope` 是必填 keyword,分批提交的中间状态会 `TypeError` |
| T4 | README 公开错误面两列表 + 迁移文档补注 | issue #7 的第二诉求,作者认为比第一条更值得改 |
| T5 | 版本 1.1.0 + CHANGELOG + Wiki 同步 | 加父类是扩大不是破坏,故 minor 而非 major |
## 保真校验(适用)
触及 ARCHITECTURE §1.4 的移植蓝本(CHS `app/domain/errors.py``app/coordination/`)。本次**有意变更**的语义仅一条(`GovernanceBackendError` 的类型归属);`retry_after_s` 非可选语义、两个 reason 值域、fail-closed 方向、记账/闸门分工、`RedisPermit` 释放侧降级五条**不得被顺带改动**,每任务完成前逐条自查。
## 独立审查修正(2026-08-06, Codex)
4 条意见全部核实属实并已折回:
1. **T3 测试证据定位错误**(重要)——原写"复用 `test_backpressure.py:176-186` 的注入桩"覆盖三条泄漏路径,实测那三个桩是 `record_success`/`record_failure`/`mark_progress` 的**记账侧降级**,与闸门路径无关;`try_acquire`/`try_enter` 全无覆盖。已改为"三条桩都要新增"并写明现状。
2. **T3 漏了既有测试构造点**(重要)——新签名 keyword-only 必填,`test_backpressure.py:176/181/186``test_errors.py:89` 的裸 `GovernanceBackendError("...")``:255/:257``QuotaGate(_L())` 漏改即 `TypeError`。已补一张同批更新清单。
3. **`__all__` 插入位置写反**(次要)——按字母序应在 `SourceDeadError` **之后**而非之前。已改。
4. **设计中 telemetry 行号过时**(次要)——`:210``:250`,系本分支加 `_AttemptUsage` 造成的漂移。设计与摘要页已同步更新。
Codex 同时独立核实了计划的可执行性锚点: 22 处构造点、三处 gate 装配、后端层 `self._scope` 位置、README/ARCH 章节行号,均与 `src/` 现状相符。
相关: [[governance-backend-error]](design)、[[m2-distributed]]
+1 -1
View File
@@ -1,3 +1,3 @@
# Query Pack
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
> 自动生成,请勿手动编辑
+5 -2
View File
@@ -1,11 +1,11 @@
---
type: schema
node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 21 字段)"
title: "表结构: llm_calls(遥测 22 字段)"
date: 2026-07-20
---
# 表结构: llm_calls(遥测 21 字段)
# 表结构: llm_calls(遥测 22 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -27,6 +27,7 @@ date: 2026-07-20
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -53,6 +54,8 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
## 采样参数口径(2026-07-31,issue #4)
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
+3 -1
View File
@@ -17,6 +17,7 @@ from polygateway.errors import (
RequestRejectedError,
ResultInvalidError,
SourceDeadError,
SourceNotConfiguredError,
TransientError,
)
from polygateway.ocr import OcrClient
@@ -31,7 +32,7 @@ from polygateway.types import (
SourceConfig,
)
__version__ = "1.0.5"
__version__ = "1.1.0"
__all__ = [
"DEFAULT_PROFILES",
@@ -58,6 +59,7 @@ __all__ = [
"ResultInvalidError",
"SourceConfig",
"SourceDeadError",
"SourceNotConfiguredError",
"TransientError",
"__version__",
"gather_bounded",
+2 -2
View File
@@ -15,7 +15,7 @@ import time
import uuid
from typing import TYPE_CHECKING
from polygateway.errors import GovernanceBackendError
from polygateway.errors import SourceNotConfiguredError
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
if TYPE_CHECKING:
@@ -89,7 +89,7 @@ class InMemoryLimiter:
def _cfg(self, source_key: str) -> SourceConfig:
cfg = self._sources.get(source_key)
if cfg is None:
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
return cfg
def _window(self) -> int:
+5 -5
View File
@@ -367,7 +367,7 @@ class RedisGate:
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
return self._decision(source_name, result)
async def record_success(
@@ -385,7 +385,7 @@ class RedisGate:
try:
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def record_failure(
@@ -407,7 +407,7 @@ class RedisGate:
try:
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def release_probe(self, entry: GateDecision) -> GateUpdate:
@@ -419,7 +419,7 @@ class RedisGate:
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
return self._update(result)
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
@@ -429,7 +429,7 @@ class RedisGate:
try:
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}") from exc
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
return int(result) / 1000.0
async def aclose(self) -> None:
+8 -8
View File
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING
from loguru import logger
from redis.exceptions import RedisError
from polygateway.errors import GovernanceBackendError
from polygateway.errors import GovernanceBackendError, SourceNotConfiguredError
from polygateway.types import GlobalLimits, SourceConfig, SourceStats
if TYPE_CHECKING:
@@ -195,7 +195,7 @@ class RedisLimiter:
def _cfg(self, source_key: str) -> SourceConfig:
cfg = self._sources.get(source_key)
if cfg is None:
raise GovernanceBackendError(f"未知源 {source_key!r}(scope={self._scope})")
raise SourceNotConfiguredError(f"未知源 {source_key!r}(scope={self._scope})")
return cfg
def _lease_keys(self, source_key: str) -> tuple[str, str]:
@@ -247,7 +247,7 @@ class RedisLimiter:
],
)
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
if ok != 1:
return None
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
@@ -265,14 +265,14 @@ class RedisLimiter:
try:
await self._release_lua(keys=[gl, sl], args=[lease_id])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 release 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
wk = self._window_keys(source_key, window)
try:
await self._settle_lua(keys=[wk["g_tpm"], wk["s_tpm"]], args=[delta, _WINDOW_TTL_S])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 settle 失败: {exc}", scope=self._scope) from exc
async def source_stats(self, source_key: str) -> SourceStats:
"""当前窗口快照;读侧 clamp ≥0(展示口径,存储保留负值)。"""
@@ -283,7 +283,7 @@ class RedisLimiter:
wk = self._window_keys(source_key, window)
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
return SourceStats(
inflight=int(res[0]),
rpm_used=max(0, int(res[1])),
@@ -295,14 +295,14 @@ class RedisLimiter:
try:
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
async def progress_age_s(self) -> float:
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
try:
res = await self._progress_age_lua(keys=[self._progress_key()])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}") from exc
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
return float("inf") if int(res) == -1 else int(res) / 1000.0
async def aclose(self) -> None:
+46 -12
View File
@@ -25,7 +25,7 @@ from polygateway.middleware.retry import RetryMW
from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.pricing import PricingTable
from polygateway.providers import get_provider
from polygateway.providers import get_capability, get_provider, resolve_thinking
from polygateway.sources import (
AdaptivePacer,
HealthAwareSelector,
@@ -51,7 +51,7 @@ if TYPE_CHECKING:
TelemetryRecorder,
Transport,
)
from polygateway.providers import ProviderProfile
from polygateway.providers import ProviderProfile, ThinkingCapability
from polygateway.types import (
BackpressurePolicy,
RetryPolicy,
@@ -61,22 +61,52 @@ if TYPE_CHECKING:
_T = TypeVar("_T")
def _guard_thinking(
sources: list[SourceConfig],
profiles: list[ProviderProfile],
capabilities: Mapping[str, ThinkingCapability] | None,
) -> None:
"""装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。
与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路
(CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
"""
for source, profile in zip(sources, profiles, strict=True):
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
source.enable_thinking,
model=source.model,
)
def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
"""
parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None:
parts.append(source.enable_thinking)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。全源 `extra_body` 皆空时
字面量与历史实现逐字相同,不触发存量缓存冷启动。
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
缓存冷启动。
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body) 而非源名摘要: 语义是"本 scope 会用哪些
# (模型, 解码参数)组合",改源名不该误触全量冷启动
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
marks = sorted(
{
json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
for s in sources
if s.extra_body
}
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
@@ -241,11 +271,13 @@ class GatewayClient:
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
capabilities: Mapping[str, ThinkingCapability] | None = None,
rng: Any = random.random,
) -> GatewayClient:
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
sources = list(settings.sources)
profiles = [get_provider(s.provider, registry=registry) for s in sources]
_guard_thinking(sources, profiles, capabilities)
strategy, escalation = _build_structured(profiles)
return cls(
scope=settings.scope,
@@ -253,7 +285,7 @@ class GatewayClient:
selector=_build_selector(settings.selector, rng=rng),
limiter=limiter or _build_limiter(settings, sources),
breaker=breaker or _build_breaker(settings),
transport=OpenAICompatTransport(registry=registry),
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
retry=settings.retry,
backpressure=settings.backpressure,
quota_full=settings.quota_full,
@@ -279,6 +311,7 @@ class GatewayClient:
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
capabilities: Mapping[str, ThinkingCapability] | None = None,
env: Mapping[str, str] | None = None,
) -> GatewayClient:
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
@@ -289,6 +322,7 @@ class GatewayClient:
cache=cache,
telemetry=telemetry,
registry=registry,
capabilities=capabilities,
)
+5 -3
View File
@@ -149,9 +149,11 @@ class GatewaySettings:
def _normalize(self) -> None:
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。
一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑
scope 的限流与熔断状态会分裂两套命名空间,各记各的,治理静默失效且不报错
`scope` 的 strip 才是要紧的那一半: 它进 Redis key(`pgw:limit:{scope}:…`
/`pgw:gate:{scope}:…`),而两个 Redis 后端在构造函数里只 `.lower()` **不 strip**
——`"llm "` 会产出 `pgw:limit:llm :…`,与 `from_env` 路的进程分裂两套命名空间。
大小写则不会: 后端自 v1.0.0 起各自 lower,`from_settings` 传 "LLM" 也落在同一
套 key 上(此处 lower 只为让 `GatewaySettings.scope` 属性两路取值一致)。
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
+2 -2
View File
@@ -120,8 +120,8 @@ class EmbeddingClient:
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
self._selector = selector
self._quota = QuotaGate(limiter)
self._breaker = BreakerGate(breaker)
self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
+50 -3
View File
@@ -5,8 +5,22 @@
"""
SCOPE_REASONS = frozenset(
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
{
"circuit_open",
"retry_exhausted",
"stalled",
"quota_exhausted",
"no_sources",
"governance_backend_down", # issue #7: 限流/熔断后端故障(fail-closed → 整个 scope 发不出请求)
}
)
GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0
"""治理后端故障的建议重投间隔(秒)。
**不是环境配置项**——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),
故取一个保守固定值;下游有自己的退避策略时可忽略本字段。取 0 会让积压任务零延迟
同时冲击已挂掉的后端,把一次故障放大成一场风暴(issue #7 §3.2)。
"""
SOURCE_REASONS = frozenset(
{
"network_error",
@@ -127,5 +141,38 @@ class AllSourcesExhausted(GatewayUnavailableError): # noqa: N818 — ARCH §6.1
"""重试预算耗尽 / 无可用源 / 配额 fail-fast 等 scope 级失败。"""
class GovernanceBackendError(PolyGatewayError):
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。"""
class SourceNotConfiguredError(PolyGatewayError):
"""源名不在限流后端的配置字典中: 装配缺陷,正常不可达。
**有意不在** `GatewayUnavailableError` 之下: 它不是"暂时不可用"而是"配置写
错了",必须消耗失败预算进死信让人看见;归入可重投家族会让配置错误的任务永远
重投、永不告警——正是 issue #7 要修的那个 bug 的镜像(§3.4)。
"""
class GovernanceBackendError(GatewayUnavailableError):
"""限流/熔断状态后端自身故障: 必须报错而非放行(防击穿网关,降级方向铁律)。
继承 `GatewayUnavailableError`(issue #7): fail-closed 意味着整个 scope 一个
请求都发不出去,语义上即 scope 级不可用。此前它是 `PolyGatewayError` 的直接
子类,只写 `except GatewayUnavailableError` 的调用方接不住,后果是"Redis 抖
一下 → 积压任务消耗业务失败预算 → 进死信",而那是运维重启即可恢复的故障。
"""
def __init__(
self,
message: str,
*,
scope: str,
retry_after_s: float = GOVERNANCE_BACKEND_RETRY_AFTER_S,
source_name: str | None = None,
) -> None:
super().__init__(
scope=scope,
reason="governance_backend_down",
retry_after_s=retry_after_s,
source_name=source_name,
)
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
self.args = (message,)
+8 -6
View File
@@ -14,8 +14,10 @@ if TYPE_CHECKING:
class BreakerGate:
"""RetryMW 面向熔断后端的唯一入口;包装一切后端异常。"""
def __init__(self, gate: ProviderGate) -> None:
def __init__(self, gate: ProviderGate, *, scope: str) -> None:
self._gate = gate
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
self._scope = scope
async def try_enter(self, source: SourceConfig, owner: str) -> GateDecision:
try:
@@ -23,7 +25,7 @@ class BreakerGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"熔断后端故障(try_enter): {exc}") from exc
raise GovernanceBackendError(f"熔断后端故障(try_enter): {exc}", scope=self._scope) from exc
async def record_success(
self, entry: GateDecision, *, count_attempt: bool = True
@@ -33,7 +35,7 @@ class BreakerGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"熔断后端故障(record_success): {exc}") from exc
raise GovernanceBackendError(f"熔断后端故障(record_success): {exc}", scope=self._scope) from exc
async def record_failure(
self, entry: GateDecision, reason: str, force_open: bool
@@ -43,7 +45,7 @@ class BreakerGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"熔断后端故障(record_failure): {exc}") from exc
raise GovernanceBackendError(f"熔断后端故障(record_failure): {exc}", scope=self._scope) from exc
async def release_probe(self, entry: GateDecision) -> GateUpdate:
try:
@@ -51,7 +53,7 @@ class BreakerGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"熔断后端故障(release_probe): {exc}") from exc
raise GovernanceBackendError(f"熔断后端故障(release_probe): {exc}", scope=self._scope) from exc
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
try:
@@ -59,4 +61,4 @@ class BreakerGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"熔断后端故障(retry_after_s): {exc}") from exc
raise GovernanceBackendError(f"熔断后端故障(retry_after_s): {exc}", scope=self._scope) from exc
+7 -5
View File
@@ -18,8 +18,10 @@ if TYPE_CHECKING:
class QuotaGate:
"""RetryMW 面向限流后端的唯一入口;包装一切后端异常。"""
def __init__(self, limiter: RateLimiter) -> None:
def __init__(self, limiter: RateLimiter, *, scope: str) -> None:
self._limiter = limiter
# 后端故障即 scope 级不可用,异常须携 scope 供调用方定位(issue #7 §3.3)
self._scope = scope
async def try_acquire(self, source: SourceConfig) -> Permit | None:
try:
@@ -27,7 +29,7 @@ class QuotaGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"限流后端故障(try_acquire): {exc}") from exc
raise GovernanceBackendError(f"限流后端故障(try_acquire): {exc}", scope=self._scope) from exc
async def stats(self, source: SourceConfig) -> SourceStats:
try:
@@ -35,7 +37,7 @@ class QuotaGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"限流后端故障(source_stats): {exc}") from exc
raise GovernanceBackendError(f"限流后端故障(source_stats): {exc}", scope=self._scope) from exc
async def mark_progress(self) -> None:
try:
@@ -43,7 +45,7 @@ class QuotaGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"限流后端故障(mark_progress): {exc}") from exc
raise GovernanceBackendError(f"限流后端故障(mark_progress): {exc}", scope=self._scope) from exc
async def progress_age_s(self) -> float:
try:
@@ -51,4 +53,4 @@ class QuotaGate:
except GovernanceBackendError:
raise
except Exception as exc:
raise GovernanceBackendError(f"限流后端故障(progress_age_s): {exc}") from exc
raise GovernanceBackendError(f"限流后端故障(progress_age_s): {exc}", scope=self._scope) from exc
+3 -2
View File
@@ -183,8 +183,8 @@ class RetryMW:
self._scope = scope
self._sources = list(sources)
self._selector = selector
self._quota = QuotaGate(limiter)
self._breaker = BreakerGate(gate)
self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(gate, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
@@ -438,6 +438,7 @@ class RetryMW:
usage_source=result.usage_source,
cached_prompt_tokens=result.cached_prompt_tokens,
model_reported=result.model_reported,
reasoning_tokens=result.reasoning_tokens,
)
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
+54 -9
View File
@@ -12,6 +12,7 @@ import asyncio
import json
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
@@ -28,6 +29,44 @@ if TYPE_CHECKING:
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
@dataclass(frozen=True)
class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为
一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。
"""
response_text: str = ""
thinking: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
usage_source: str = "unavailable"
ttft_ms: float | None = None
max_inter_token_ms: float | None = None
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
"""从响应取用量;`None`(失败尝试)返回全默认视图。"""
if response is None:
return cls()
return cls(
response_text=response.content,
thinking=response.thinking,
prompt_tokens=response.prompt_tokens,
completion_tokens=response.completion_tokens,
usage_source=response.usage_source,
ttft_ms=response.ttft_ms,
max_inter_token_ms=response.max_inter_token_ms,
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
)
class TelemetryEmitter:
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
@@ -46,24 +85,26 @@ class TelemetryEmitter:
error: str | None,
) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
usage = _AttemptUsage.of(response)
await self._record(
request=request,
call_id=call_id,
model=source.model,
provider=source.provider,
source_name=source.name,
response_text=response.content if response else "",
thinking=response.thinking if response else "",
prompt_tokens=response.prompt_tokens if response else 0,
completion_tokens=response.completion_tokens if response else 0,
usage_source=response.usage_source if response else "unavailable",
response_text=usage.response_text,
thinking=usage.thinking,
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
usage_source=usage.usage_source,
latency_ms=latency_ms,
ttft_ms=response.ttft_ms if response else None,
max_inter_token_ms=response.max_inter_token_ms if response else None,
ttft_ms=usage.ttft_ms,
max_inter_token_ms=usage.max_inter_token_ms,
cache_hit=False,
error=error,
cached_prompt_tokens=response.cached_prompt_tokens if response else None,
model_reported=response.model_reported if response else None,
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
)
@@ -90,6 +131,7 @@ class TelemetryEmitter:
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
@@ -117,6 +159,7 @@ class TelemetryEmitter:
error=error,
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
)
@@ -142,6 +185,7 @@ class TelemetryEmitter:
cached_prompt_tokens: int | None,
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -182,6 +226,7 @@ class TelemetryEmitter:
cached_prompt_tokens=cached_prompt_tokens,
model_reported=model_reported,
sampling=sampling,
reasoning_tokens=reasoning_tokens,
)
except asyncio.CancelledError:
raise
+2 -2
View File
@@ -119,8 +119,8 @@ class OcrClient:
self._sources = strip_unsupported_extra_body(list(sources), path="OCR")
self._selector = selector
self._feed_health = isinstance(selector, OutcomeAwareSelector)
self._quota = QuotaGate(limiter)
self._breaker = BreakerGate(breaker)
self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
+1
View File
@@ -275,4 +275,5 @@ class TelemetryRecorder(Protocol):
cached_prompt_tokens: int | None,
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
) -> None: ...
+174 -16
View File
@@ -10,24 +10,37 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class ProviderProfile:
"""单个 provider 的能力与差异声明。
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认);
strip_think_tags 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
supports_native_schema 供 D14 阶梯选择原生 response_format 策略
True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。
注: 某个 provider 的两档若皆为空字典(如 openai/minimax),说明该 provider
无已知的推理开关参数——此时 `enable_thinking` 对它**不产生任何效果**,
而非静默生效。需要下发自定义参数时用 `SourceConfig.extra_body`。
两档各有三种取值,**语义互不重叠**(issue #5):
========== ==========================================================
``{...}`` 已知的注入片段
``{}`` 已知**无需注入**任何参数即处于该档
``None`` **未知**: 本库不知道该 provider 如何表达这一档
========== ==========================================================
`None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否
关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。
"""
name: str
thinking_on: dict[str, Any]
thinking_off: dict[str, Any]
thinking_on: Mapping[str, Any] | None
thinking_off: Mapping[str, Any] | None
strip_think_tags: bool
supports_native_schema: bool = False
@@ -47,26 +60,171 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
thinking_off={"thinking": {"type": "disabled"}},
strip_think_tags=False,
),
# 两档皆空 ⇒ `enable_thinking` 对本 provider **不产生任何效果**(调用方
# 以为关掉了实际没关)。真需要控制推理时经 `SourceConfig.extra_body` 下发
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
"openai": ProviderProfile(
name="openai",
thinking_on={},
thinking_off={},
thinking_on=None,
thinking_off=None,
strip_think_tags=False,
),
# OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理。
# 同上: 两档皆空 ⇒ `enable_thinking` 对 MiniMax 源不产生任何效果
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
"minimax": ProviderProfile(
name="minimax",
thinking_on={},
thinking_off={},
thinking_on={"reasoning_effort": "medium"},
thinking_off={"reasoning_effort": "none"},
strip_think_tags=False,
),
}
)
class ThinkingUnsupportedError(ValueError):
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
错误误贴成"推理开关无法满足")。
"""
@dataclass(frozen=True)
class ThinkingCapability:
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
"""
can_disable: bool
evidence: str
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{
"MiniMax-M3": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
),
"MiniMax-M2.7": ThinkingCapability(
can_disable=False,
evidence=(
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
),
),
"MiniMax-M2.5": ThinkingCapability(
can_disable=False,
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
),
"qwen3.7-plus": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
),
"deepseek-v4-pro": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
),
}
)
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
def get_capability(
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
) -> ThinkingCapability | None:
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
"""
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
def register_capability(
model: str,
capability: ThinkingCapability,
*,
base: Mapping[str, ThinkingCapability] | None = None,
) -> dict[str, ThinkingCapability]:
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
table = dict(DEFAULT_CAPABILITIES if base is None else base)
table[model] = capability
return table
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
enable_thinking: bool | None,
*,
model: str,
warn_unregistered: bool = True,
) -> Mapping[str, Any]:
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
`capability` 为 None(未登记)时无从从别处取得模型名。
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
调用再喊只会刷屏。判定结果不受此参数影响。
"""
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
if enable_thinking is None:
return {}
slot = profile.thinking_on if enable_thinking else profile.thinking_off
direction = "thinking_on" if enable_thinking else "thinking_off"
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
if slot is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r}{direction} 形态未知(模型 {model!r}): "
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
)
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
if capability is None:
if warn_unregistered:
_warn_unregistered(model, profile, slot)
return slot
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
if enable_thinking is False and not capability.can_disable:
raise ThinkingUnsupportedError(
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
f"需要关闭思维链请换用支持关闭的模型"
)
return slot
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
model,
profile.name,
dict(slot),
)
def get_provider(
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
) -> ProviderProfile:
+4 -1
View File
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT
sampling TEXT,
reasoning_tokens INTEGER
);
"""
@@ -51,6 +52,7 @@ _BACKFILL = (
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
)
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
@@ -81,6 +83,7 @@ _COLUMNS = (
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
)
_INSERT = (
+4 -1
View File
@@ -37,7 +37,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT
sampling TEXT,
reasoning_tokens INTEGER
);
"""
@@ -47,6 +48,7 @@ _BACKFILL_COLUMNS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
)
_COLUMNS = (
@@ -71,6 +73,7 @@ _COLUMNS = (
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
)
_INSERT = (
+60 -8
View File
@@ -21,7 +21,14 @@ from polygateway.errors import (
SourceDeadError,
TransientError,
)
from polygateway.providers import ProviderProfile, get_provider
from polygateway.providers import (
ProviderProfile,
ThinkingCapability,
ThinkingUnsupportedError,
get_capability,
get_provider,
resolve_thinking,
)
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
@@ -177,6 +184,25 @@ def _coerce_cached_tokens(usage: Any) -> int | None:
return cached
def _coerce_reasoning_tokens(usage: Any) -> int | None:
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。
与 `_coerce_cached_tokens` 逐条同构(两者是 OpenAI 兼容 usage 里对称的一对):
`0` 如实保留、负数与非整数归 None、`bool` 显式排除。差别只在语义——本字段
的 None 是"**本次调用**未上报"而非"该源不上报": 中转在上游不返回 usage 时
会本地补算并整体替换 usage 对象,把 details 一并吃掉(findings §4c)。
"""
if not isinstance(usage, dict):
return None
details = usage.get("completion_tokens_details")
if not isinstance(details, dict):
return None
reasoning = details.get("reasoning_tokens")
if isinstance(reasoning, bool) or not isinstance(reasoning, int) or reasoning < 0:
return None
return reasoning
def _coerce_model_reported(value: Any) -> str | None:
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
@@ -265,9 +291,14 @@ class OpenAICompatTransport:
self,
*,
registry: Mapping[str, ProviderProfile] | None = None,
capabilities: Mapping[str, ThinkingCapability] | None = None,
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
) -> None:
self._registry = registry
self._capabilities = capabilities
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
self._warned_models: set[str] = set()
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -290,10 +321,20 @@ class OpenAICompatTransport:
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
if stream:
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
if source.enable_thinking is True:
payload.update(profile.thinking_on)
elif source.enable_thinking is False:
payload.update(profile.thinking_off)
# 形态(provider 级)与能力(model 级)在此相遇;不可满足时 ValueError,
# 由 complete() 翻译为四分类之一(issue #5)
capability = get_capability(source.model, table=self._capabilities)
first_time = source.model not in self._warned_models
self._warned_models.add(source.model)
payload.update(
resolve_thinking(
profile,
capability,
source.enable_thinking,
model=source.model,
warn_unregistered=first_time,
)
)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换
payload.update(source.extra_body)
@@ -311,9 +352,18 @@ class OpenAICompatTransport:
) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
profile = get_provider(source.provider, registry=self._registry)
payload = self._build_payload(
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
)
try:
payload = self._build_payload(
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
)
except ThinkingUnsupportedError as exc:
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
# 专用类型而非宽 catch ValueError —— 后者会把序列化等无关错误误贴标签
raise RequestRejectedError(
f"{source.name} 推理开关无法满足: {exc}",
source_name=source.name,
operation="chat",
) from exc
url = source.base_url.rstrip("/") + "/chat/completions"
client = self._client_for(source)
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
@@ -400,6 +450,7 @@ class OpenAICompatTransport:
raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
)
def _check_done(
@@ -484,6 +535,7 @@ class OpenAICompatTransport:
raw={"usage": body.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
model_reported=_coerce_model_reported(body.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
)
async def aclose(self) -> None:
+11 -1
View File
@@ -99,6 +99,15 @@ class LLMResponse:
model_reported: str | None = None
"""API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能
分叉——供应商把别名指向新权重时,实验复现必须认这个串。"""
reasoning_tokens: int | None = None
"""推理消耗的输出 token 数(含在 `completion_tokens` 内,故不影响成本总额,
只补归因;issue #6)。
`None` = **本次调用**未上报,**不是**"该源不上报"——中转网关在上游不返回
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。"""
@dataclass(frozen=True)
@@ -151,9 +160,10 @@ class TransportResult:
ttft_ms: float | None
max_inter_token_ms: float | None
raw: dict[str, Any]
# —— 可观测字段(issue #3;带默认值,非 OpenAI 兼容的 transport 可不填)——
# —— 可观测字段(issue #3/#6;带默认值,非 OpenAI 兼容的 transport 可不填)——
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
@dataclass(frozen=True)
+444
View File
@@ -0,0 +1,444 @@
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
验证不了"这个参数在这个模型上到底关没关掉推理"
两条判据纪律(来自 findings §4c 的实测教训):
1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`** 两档的输出
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判
`reasoning_tokens` 在同一批 30 轮里干净分开关闭 15/15 None,
开启 15/15 大于 0
2. **另配一个不含魔数的确定性锚点**( L2b): 同一模型上,关闭档的
`prompt_tokens` 严格小于开启档供应商在开启时注入了推理指令,输入侧
token 数随之变大这是相对比较,不硬编码任何具体数值
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足** 中转在上游不返回
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
开启方向因此可能偶尔观测不到;关闭方向不受影响
源不可用一律 `skip` 并在报告中记为未覆盖,**绝不静默计入通过**
"""
import dataclasses
import json
import os
from collections import Counter
from datetime import datetime
from pathlib import Path
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings
from polygateway.errors import (
AllSourcesExhausted,
RequestRejectedError,
SourceDeadError,
TransientError,
)
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
# 存档报告。"不自动门控"不等于"可跳过"。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)"
),
]
_OUT_DIR = Path("tests/outputs/e2e")
_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数,
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
_ON_MIN_COMPLETION = 100
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
_ROWS: list[dict] = []
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是
# 在 L8 里被"源不可用"这个假理由吞掉
_MODEL_PROVIDER = {
"MiniMax-M3": "minimax",
"MiniMax-M2.7": "minimax",
"MiniMax-M2.5": "minimax",
"qwen3.7-plus": "qwen",
"deepseek-v4-pro": "deepseek",
}
def _base_settings() -> GatewaySettings:
# 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次
# 变成对第一轮的回放,整组判据随之失效
return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"})
def _settings(**source_overrides) -> GatewaySettings:
base = _base_settings()
source = dataclasses.replace(base.sources[0], **source_overrides)
return dataclasses.replace(base, sources=(source,))
async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]:
"""跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。"""
client = GatewayClient.from_settings(_settings(**source_overrides))
observations = []
try:
for i in range(rounds):
resp = await client.chat(
[{"role": "user", "content": _PROMPT}],
stream=stream,
# 每轮独立 salt: 即便某层缓存意外开着也不会回放
cache_salt=f"thinking-live-{i}",
)
observations.append(
{
"round": i + 1,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
"content": resp.content[:60],
}
)
finally:
await client.aclose()
return observations
def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None:
_ROWS.append(
{
"matrix": matrix_id,
"desc": desc,
"status": status,
"detail": detail,
"observations": observations or [],
}
)
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只看 reasoning_tokens。
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理加长度门只会
把这种正常波动误判成"没关掉"
"""
return obs["reasoning_tokens"] in (None, 0)
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据
"""
reasoning = obs["reasoning_tokens"]
if reasoning is not None:
return reasoning > 0
return obs["completion_tokens"] > _ON_MIN_COMPLETION
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
"""源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。"""
_record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200])
pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}")
@pytest.fixture(scope="module", autouse=True)
def _write_report():
yield
_OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
lines = [
"# 推理开关与 reasoning_tokens 真实 API 验证",
"",
f"- 时间: {ts}",
f"- 每档轮数: {_ROUNDS}",
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"",
"## 矩阵结论",
"",
"| 矩阵 | 场景 | 结论 | 说明 |",
"|---|---|---|---|",
]
total_calls = 0
for row in _ROWS:
detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160]
lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |")
total_calls += len(row["observations"])
lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""]
for row in _ROWS:
if not row["observations"]:
continue
lines += [f"### {row['matrix']}{row['desc']}", "", "```json"]
lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2))
lines += ["```", ""]
uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")]
if uncovered:
lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""]
path.write_text("\n".join(lines), encoding="utf-8")
print(f"\n[e2e 报告] {path}")
class TestMiniMaxM3:
"""M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。"""
async def test_l1_disable_actually_disables(self):
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False)
offs = [o for o in obs if _reasoning_off(o)]
_record(
"L1",
"enable_thinking=False(流式)",
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)} 轮确认未推理",
obs,
)
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
async def test_l2_enable_actually_enables(self):
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True)
ons = [o for o in obs if _reasoning_on(o)]
_record(
"L2",
"enable_thinking=True(流式,注入 medium)",
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
f"{len(ons)}/{len(obs)} 轮观察到推理",
obs,
)
assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}"
async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self):
"""确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。
供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大这是
本组唯一不依赖输出侧噪声的证据,且是相对比较不硬编码任何具体数值,
供应商改模板也不会让它假红
"""
rounds = max(3, _ROUNDS // 3)
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True)
off_max = max(o["prompt_tokens"] for o in off)
on_min = min(o["prompt_tokens"] for o in on)
_record(
"L2b",
"关闭/开启的 prompt_tokens 可分",
"PASS" if off_max < on_min else "FAIL",
f"关闭档最大 {off_max} < 开启档最小 {on_min}",
off + on,
)
assert off_max < on_min, (
f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型"
)
async def test_l3_no_opinion_is_the_model_default(self):
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None)
# M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。
# 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是
# L2b 的 prompt_tokens 与 L3b 的乱码值反证
quiet = [o for o in obs if _reasoning_off(o)]
_record(
"L3",
"enable_thinking=None(不干预,基线)",
"PASS" if len(quiet) == len(obs) else "FAIL",
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
obs,
)
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
async def test_l3b_none_is_recognised_not_silently_dropped(self):
"""反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。
L1(关闭) L3(不干预) M3 **同分布**因为 M3 默认档本就不推理
所以 L1 单独看不能区分"`none` 真的被消费""`none` 被中转吞了",而后者
正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。
判别方法: 发一个**非法值**若未知值会被静默丢弃,它的表现应与"不注入"
一致(不推理);实测它反而开启了推理,说明网关认这个键只是不认这个值
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值
"""
rounds = max(3, _ROUNDS // 3)
bogus = await _run_rounds(
rounds,
model="MiniMax-M3",
enable_thinking=None,
extra_body={"reasoning_effort": "definitely-not-a-real-level"},
)
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
bogus_on = [o for o in bogus if _reasoning_on(o)]
off_quiet = [o for o in off if _reasoning_off(o)]
ok = len(bogus_on) * 2 > len(bogus) and len(off_quiet) == len(off)
_record(
"L3b",
"非法值反证 none 被识别",
"PASS" if ok else "FAIL",
f"非法值 {len(bogus_on)}/{len(bogus)} 轮推理,none {len(off_quiet)}/{len(off)} 轮不推理"
"(两者表现不同 ⇒ none 非被丢弃)",
bogus + off,
)
assert len(bogus_on) * 2 > len(bogus), (
f"非法值未开启推理,无法排除'未知值被静默丢弃'这一伪解释: {bogus}"
)
assert len(off_quiet) == len(off), f"none 未关闭推理: {off}"
async def test_l4_extra_body_overrides_the_profile(self):
"""profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。
判据是行为而非报文: extra_body 没赢,拿到的就是 none 的结果(不推理)
"""
rounds = max(3, _ROUNDS // 2)
obs = await _run_rounds(
rounds,
model="MiniMax-M3",
enable_thinking=False,
extra_body={"reasoning_effort": "high"},
)
ons = [o for o in obs if _reasoning_on(o)]
_record(
"L4",
"extra_body 覆盖 profile 注入",
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)",
obs,
)
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
async def test_l5_non_stream_path_matches_stream(self):
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
rounds = max(3, _ROUNDS // 2)
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
offs = [o for o in off if _reasoning_off(o)]
ons = [o for o in on if _reasoning_on(o)]
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
_record(
"L5",
"非流式路径重跑 L1/L2",
"PASS" if ok else "FAIL",
f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)}",
off + on,
)
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
class TestOtherProviders:
"""qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们""""
@pytest.mark.parametrize(
("matrix", "provider", "model"),
[("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")],
)
async def test_existing_profiles_still_disable(self, matrix, provider, model):
desc = f"{provider} enable_thinking=False"
try:
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False)
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
# 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** ——
# 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默
_skip_if_unreachable(exc, matrix, desc)
offs = [o for o in obs if _reasoning_off(o)]
_record(
matrix,
desc,
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)} 轮确认未推理",
obs,
)
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
class TestCapabilityDrift:
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
def test_every_capability_has_a_provider_mapping(self):
"""能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。"""
missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER))
assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}"
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
async def test_declared_capability_matches_reality(self, model):
cap = get_capability(model)
provider = _MODEL_PROVIDER[model]
rounds = max(3, _ROUNDS // 2)
desc = f"{model} 声明 can_disable={cap.can_disable}"
if not cap.can_disable:
# 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用)
with pytest.raises(ValueError, match=model):
GatewayClient.from_settings(
_settings(provider=provider, model=model, enable_thinking=False)
)
_record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致")
return
try:
obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False)
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
_skip_if_unreachable(exc, "L8", desc)
offs = [o for o in obs if _reasoning_off(o)]
verdict = Counter(_reasoning_off(o) for o in obs)
_record(
"L8",
desc,
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
obs,
)
assert len(offs) == len(obs), (
f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES"
)
class TestAssemblyGuardAgainstRealConfig:
"""L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效""""
def test_l9_m27_rejected_at_assembly(self):
with pytest.raises(ValueError, match="MiniMax-M2.7"):
GatewayClient.from_settings(
_settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
)
_record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求")
def test_l9_unknown_shape_rejected_at_assembly(self):
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(
_settings(provider="openai", model="kimi-k3", enable_thinking=False)
)
_record("L9", "provider=openai 形态未知", "PASS", "装配期报错并指路")
async def test_transport_layer_rejects_when_guard_is_bypassed(self):
"""构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。"""
settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
client = GatewayClient.from_settings(
dataclasses.replace(
settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),)
)
)
try:
# 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport
bad = dataclasses.replace(settings.sources[0], enable_thinking=False)
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
await client._terminal._transport.complete(
messages=[{"role": "user", "content": _PROMPT}],
source=bad,
stream=True,
overlay={},
call_id="e2e-guard",
)
finally:
await client.aclose()
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
@@ -43,6 +43,7 @@ _EXPECTED_COLUMNS = [
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
@@ -107,6 +108,7 @@ async def _record_minimal(
"cached_prompt_tokens": None,
"model_reported": None,
"sampling": None,
"reasoning_tokens": None,
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -16,7 +16,11 @@ import pytest
from polygateway.backends.redis.breaker import RedisGate
from polygateway.backends.redis.limiter import RedisLimiter
from polygateway.client import GatewayClient
from polygateway.errors import AllSourcesExhausted, GovernanceBackendError
from polygateway.errors import (
AllSourcesExhausted,
GatewayUnavailableError,
GovernanceBackendError,
)
from polygateway.sources import RoundRobinSelector
from polygateway.types import (
BackpressurePolicy,
@@ -225,7 +229,11 @@ async def test_cancel_in_flight_releases_lease(clients):
async def test_redis_down_admission_fails_closed():
"""Redis 不可达 → 准入侧抛 GovernanceBackendError,绝不放行(库铁律)。"""
"""Redis 不可达 → 准入侧报错绝不放行(库铁律),且以 scope 级形态到达调用方。
issue #7: 调用方只写 `except GatewayUnavailableError` 就该覆盖后端故障——
真实 Redis 掉线是这条链路唯一的端到端证据,故断言收紧到 scope 级语义
"""
import redis.asyncio as aioredis
dead = aioredis.from_url(
@@ -240,9 +248,12 @@ async def test_redis_down_admission_fails_closed():
lease_ttl_s=30.0,
)
gate = RedisGate(config=_CFG, redis=dead, scope="t-dead")
with pytest.raises(GovernanceBackendError):
await limiter.try_acquire("s1", 0)
with pytest.raises(GovernanceBackendError):
await gate.try_enter("s1", "w")
for call in (limiter.try_acquire("s1", 0), gate.try_enter("s1", "w")):
with pytest.raises(GatewayUnavailableError) as ei:
await call
assert isinstance(ei.value, GovernanceBackendError)
assert ei.value.reason == "governance_backend_down"
assert ei.value.scope == "t-dead"
assert ei.value.retry_after_s > 0
finally:
await dead.aclose()
+77 -6
View File
@@ -11,7 +11,13 @@ import pytest
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import AllSourcesExhausted, GovernanceBackendError, TransientError
from polygateway.errors import (
AllSourcesExhausted,
GatewayUnavailableError,
GovernanceBackendError,
SourceNotConfiguredError,
TransientError,
)
from polygateway.middleware.retry import RetryMW, backoff_delay
from polygateway.sources import RoundRobinSelector, SourceCooldownMemo
from polygateway.types import (
@@ -173,17 +179,17 @@ class TestStallQuadrants:
class _GateSuccessBroken(InMemoryGate):
async def record_success(self, entry):
raise GovernanceBackendError("redis 抖动")
raise GovernanceBackendError("redis 抖动", scope="llm")
class _GateFailureBroken(InMemoryGate):
async def record_failure(self, entry, reason, force_open):
raise GovernanceBackendError("redis 抖动")
raise GovernanceBackendError("redis 抖动", scope="llm")
class _LimiterProgressBroken(InMemoryLimiter):
async def mark_progress(self):
raise GovernanceBackendError("redis 抖动")
raise GovernanceBackendError("redis 抖动", scope="llm")
class TestAccountingDegradation:
@@ -252,6 +258,71 @@ class TestQuotaGateProgressAge:
async def progress_age_s(self):
raise OSError("down")
assert await QuotaGate(_L()).progress_age_s() == 12.5
assert await QuotaGate(_L(), scope="llm").progress_age_s() == 12.5
with pytest.raises(GovernanceBackendError):
await QuotaGate(_Broken()).progress_age_s()
await QuotaGate(_Broken(), scope="llm").progress_age_s()
class TestUnknownSourceIsAssemblyDefect:
"""未知源 = 限流后端的源名单与治理循环对不上,是装配缺陷不是后端故障。
两个后端行为必须一致(Redis 版对应用例在 `test_redis_key_layout.py::
TestConversions::test_unknown_source_rejected`);内存版此前无覆盖,
该分支从未被测过(issue #7 §3.4)。
"""
def test_memory_limiter_rejects_unknown_source(self):
limiter = InMemoryLimiter(
scope="llm", sources={"s1": make_source("s1")}, global_limits=_NO_GLOBAL
)
with pytest.raises(SourceNotConfiguredError) as ei:
limiter._cfg("nope")
# 关键: 若归入 scope 级家族,配置写错的任务会永远延期重投、永不进死信
assert not isinstance(ei.value, GatewayUnavailableError)
class TestGateFailuresReachCallersAsScopeLevel:
"""三条闸门泄漏路径必须以 scope 级不可用的形态到达调用方(issue #7)。
记账路径由 `_record_quietly` 降级为 warning,但闸门路径没有那层包裹,会一路
抛给调用方只写 `except GatewayUnavailableError` 的调用方此前接不住,后果
Redis 抖一下就让积压任务烧掉业务失败预算进死信而那是运维重启即可恢复
的故障三条路径逐一钉住,防止将来任何一条被漏掉
"""
async def test_try_acquire_failure_is_scope_level(self):
from polygateway.middleware.ratelimit import QuotaGate
class _Broken:
async def try_acquire(self, name, est):
raise OSError("down")
with pytest.raises(GatewayUnavailableError) as ei:
await QuotaGate(_Broken(), scope="LLM").try_acquire(make_source("s1"))
assert ei.value.scope == "llm"
assert ei.value.reason == "governance_backend_down"
assert ei.value.retry_after_s > 0 # 0 会让积压任务零延迟冲击已挂的后端
async def test_try_enter_failure_is_scope_level(self):
from polygateway.middleware.breaker import BreakerGate
class _Broken:
async def try_enter(self, name, owner):
raise OSError("down")
with pytest.raises(GatewayUnavailableError) as ei:
await BreakerGate(_Broken(), scope="LLM").try_enter(make_source("s1"), "owner")
assert ei.value.scope == "llm"
assert ei.value.reason == "governance_backend_down"
async def test_progress_age_failure_is_scope_level(self):
from polygateway.middleware.ratelimit import QuotaGate
class _Broken:
async def progress_age_s(self):
raise OSError("down")
with pytest.raises(GatewayUnavailableError) as ei:
await QuotaGate(_Broken(), scope="LLM").progress_age_s()
assert ei.value.scope == "llm"
assert ei.value.reason == "governance_backend_down"
+27
View File
@@ -202,6 +202,33 @@ class TestModelFingerprint:
assert plain != tuned
assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认
def test_enable_thinking_changes_fingerprint(self):
"""issue #5 配套: thinking 一旦真正改变请求体,就必须进缓存身份。
否则"关掉推理后重启"会读到开着推理时缓存的旧响应issue #4 为
temperature 写过逐字相同的理由
"""
from polygateway.client import build_model_fingerprint
plain = build_model_fingerprint([_source()])
off = build_model_fingerprint([_source(enable_thinking=False)])
on = build_model_fingerprint([_source(enable_thinking=True)])
assert len({plain, off, on}) == 3
def test_extra_body_only_fingerprint_is_byte_identical_to_before(self):
"""只配 extra_body、不表态 thinking 的存量源不得触发冷启动。
字面量在此硬编码: 这条断言的价值全在"逐字相同",改实现时必须先看见它红
"""
import hashlib
import json
from polygateway.client import build_model_fingerprint
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
def test_source_rename_does_not_change_fingerprint(self):
"""指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。"""
from polygateway.client import build_model_fingerprint
+30
View File
@@ -460,6 +460,36 @@ class TestCrossFieldInvariants:
with pytest.raises(ValueError, match="lease_ttl_s"):
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
# —— 推理开关的装配守卫(issue #5)——
def _thinking_sources(self, provider, model, enable_thinking):
base = self._base()
src = dataclasses.replace(
base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
)
return dataclasses.replace(base, sources=(src,))
def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
"""M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
with pytest.raises(ValueError, match="MiniMax-M2.7"):
GatewayClient.from_settings(settings)
def test_unknown_thinking_shape_fails_at_assembly(self):
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
settings = self._thinking_sources("openai", "kimi-k3", False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(settings)
def test_supported_combination_assembles(self):
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
assert GatewayClient.from_settings(settings) is not None
def test_not_taking_a_position_never_trips_the_guard(self):
"""enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
settings = self._thinking_sources("openai", "kimi-k3", None)
assert GatewayClient.from_settings(settings) is not None
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
base = self._base()
+54 -1
View File
@@ -3,6 +3,8 @@
import pytest
from polygateway.errors import (
GOVERNANCE_BACKEND_RETRY_AFTER_S,
SCOPE_REASONS,
AllSourcesExhausted,
CircuitOpenError,
GatewayUnavailableError,
@@ -11,6 +13,7 @@ from polygateway.errors import (
RequestRejectedError,
ResultInvalidError,
SourceDeadError,
SourceNotConfiguredError,
TransientError,
)
@@ -86,6 +89,56 @@ class TestGatewayUnavailable:
class TestBackendFailure:
def test_governance_backend_error_is_not_transient(self):
"""限流/熔断后端故障必须报错不放行,且不落入可重试分类。"""
exc = GovernanceBackendError("redis down")
exc = GovernanceBackendError("redis down", scope="llm")
assert isinstance(exc, PolyGatewayError)
assert not isinstance(exc, TransientError)
def test_is_scope_level_unavailability(self):
"""fail-closed 时整个 scope 一个请求都发不出去,调用方一条 except 应覆盖(issue #7)。"""
exc = GovernanceBackendError("限流后端 try_acquire 失败: boom", scope="LLM")
assert isinstance(exc, GatewayUnavailableError)
assert exc.reason == "governance_backend_down"
assert exc.scope == "llm" # 与既有 scope 级异常同款: 归一化小写
assert exc.retry_after_s == GOVERNANCE_BACKEND_RETRY_AFTER_S
def test_diagnostic_message_survives_reparenting(self):
"""父类把 message 覆写为模板串,而各构造点的诊断串是排障主线索(§3.5)。"""
exc = GovernanceBackendError("熔断后端 try_enter 失败: boom", scope="llm")
assert str(exc) == "熔断后端 try_enter 失败: boom"
def test_retry_after_overridable(self):
exc = GovernanceBackendError("redis down", scope="llm", retry_after_s=30.0)
assert exc.retry_after_s == 30.0
class TestSourceNotConfigured:
"""装配缺陷有意留在 scope 级家族之外(issue #7 §3.4,Q1 人类拍板)。"""
def test_is_domain_error_but_not_scope_level(self):
exc = SourceNotConfiguredError("未知源 'nope'(scope=llm)")
assert isinstance(exc, PolyGatewayError)
# 关键断言: 归入可重投家族会让配置写错的任务永远重投、永不进死信
assert not isinstance(exc, GatewayUnavailableError)
def test_exported_at_package_top_level(self):
import polygateway
assert polygateway.SourceNotConfiguredError is SourceNotConfiguredError
assert "SourceNotConfiguredError" in polygateway.__all__
class TestGovernanceBackendReason:
"""新 scope 级 reason 值域(issue #7 §3.1)。"""
def test_reason_admitted_to_scope_domain(self):
assert "governance_backend_down" in SCOPE_REASONS
def test_gateway_unavailable_accepts_the_new_reason(self):
exc = AllSourcesExhausted(
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
)
assert exc.reason == "governance_backend_down"
def test_retry_after_default_is_non_zero(self):
"""取 0 会让积压任务零延迟冲击已挂掉的后端(§3.2)。"""
assert GOVERNANCE_BACKEND_RETRY_AFTER_S > 0
+174
View File
@@ -7,6 +7,7 @@ import json
import httpx
import pytest
from loguru import logger
from polygateway.errors import (
RequestRejectedError,
@@ -394,6 +395,81 @@ class TestObservabilityFields:
assert set(result.raw) == {"usage"}
class TestReasoningTokens:
"""issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。
实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报
0;且中转在上游不返回 usage 时会本地补算并吃掉该对象 None 的语义是
"本次调用未上报",不是"该源不上报"(findings §4c)
"""
def _reasoning_usage(self, reasoning):
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
async def test_stream_reads_reasoning_tokens(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens == 7
async def test_non_stream_reads_reasoning_tokens(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": self._reasoning_usage(7),
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.reasoning_tokens == 7
async def test_zero_reasoning_tokens_is_a_real_zero(self):
"""0(上报了且确实没推理)与 None(本次未上报)必须可区分。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens == 0
async def test_usage_without_details_is_none(self):
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
async def test_malformed_reasoning_tokens_degrade_to_none(self, bad):
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad)))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
async def test_details_not_a_dict_is_none(self):
def handler(request):
usage = {**_USAGE, "completion_tokens_details": "oops"}
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
result = await _complete(_transport_for(handler), _source())
assert result.reasoning_tokens is None
async def test_salvage_path_records_none_not_zero(self):
"""打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), done=False)
result = await _complete(_transport_for(handler), _source(missing_done="salvage"))
assert result.reasoning_tokens is None
class TestNonStreamFastPath:
async def test_non_stream_parses_message(self):
def handler(request):
@@ -431,6 +507,104 @@ class TestRequestShaping:
assert "enable_thinking" not in seen
assert seen["stream_options"] == {"include_usage": True}
@pytest.mark.parametrize(
("enable_thinking", "expected"),
[(True, "medium"), (False, "none")],
)
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
await _complete(_transport_for(handler), source)
assert seen["reasoning_effort"] == expected
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
async def test_extra_body_overrides_the_profile_slot(self):
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
seen = {}
def handler(request):
seen.update(json.loads(request.content))
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm",
provider="minimax",
model="MiniMax-M3",
enable_thinking=True,
extra_body={"reasoning_effort": "high"},
)
await _complete(_transport_for(handler), source)
assert seen["reasoning_effort"] == "high"
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
裸异常会逃出 chat() 它不属错误四分类TelemetryMW 也不捕,结果是一行
遥测都没有就崩了(设计 §5.1)
"""
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
await _complete(_transport_for(handler), source)
async def test_unregistered_model_warns_only_once_per_source(self):
"""未登记模型的告警不能打在请求热路径上: 装配期已喊过,逐次再喊是刷屏。"""
def handler(request):
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(name="mm", provider="minimax", model="MiniMax-M99", enable_thinking=False)
transport = _transport_for(handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, source)
await _complete(transport, source)
await _complete(transport, source)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M99" in m]
assert len(hits) == 1, f"三次调用应只告警一次,实得 {len(hits)}"
async def test_unrelated_value_error_is_not_mislabelled(self, monkeypatch):
"""只捕 ThinkingUnsupportedError: 无关的 ValueError 不该被贴成推理开关的错。
今天 `_build_payload` 里只有 resolve_thinking 会抛 ValueError,所以这条
是防御未来 但正因如此才要钉住: 将来谁在那里加一处校验, catch
把它的错误信息盖掉,而这个用例会先红
"""
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
def _boom(*args, **kwargs):
raise ValueError("故意的无关错误")
monkeypatch.setattr("polygateway.transports.openai_compat.resolve_thinking", _boom)
with pytest.raises(ValueError, match="故意的无关错误") as exc:
await _complete(_transport_for(handler), _source(enable_thinking=False))
assert "推理开关" not in str(exc.value)
assert not isinstance(exc.value, RequestRejectedError)
async def test_unknown_shape_is_rejected(self):
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="register_provider"):
await _complete(_transport_for(handler), source)
async def test_overlay_merged_into_payload(self):
seen = {}
+1
View File
@@ -117,6 +117,7 @@ class _DummyRecorder:
cached_prompt_tokens,
model_reported,
sampling,
reasoning_tokens,
) -> None: ...
+97 -4
View File
@@ -1,12 +1,18 @@
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
import pytest
from loguru import logger
from polygateway.providers import (
DEFAULT_CAPABILITIES,
DEFAULT_PROFILES,
ProviderProfile,
ThinkingCapability,
get_capability,
get_provider,
register_capability,
register_provider,
resolve_thinking,
)
@@ -24,14 +30,21 @@ class TestDefaultProfiles:
assert p.thinking_off == {"thinking": {"type": "disabled"}}
assert p.strip_think_tags is False
def test_openai_baseline_profile(self):
def test_openai_slots_are_unknown_not_empty(self):
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
故不能下发任何厂商方言参数None = 形态未知 配了 enable_thinking 即报错,
而不是空字典那种"注入了个寂寞"的静默失效
"""
p = get_provider("openai")
assert p.thinking_on == {} and p.thinking_off == {}
assert p.thinking_on is None and p.thinking_off is None
assert p.strip_think_tags is False
def test_minimax_baseline_profile(self):
def test_minimax_profile_uses_reasoning_effort(self):
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
p = get_provider("minimax")
assert p.thinking_on == {} and p.thinking_off == {}
assert p.thinking_off == {"reasoning_effort": "none"}
assert p.thinking_on == {"reasoning_effort": "medium"}
assert p.strip_think_tags is False
def test_unknown_provider_fails_loudly(self):
@@ -62,3 +75,83 @@ class TestPureFunctionRegistration:
def test_default_profiles_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestThinkingCapability:
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
def test_registered_models_carry_evidence(self):
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
cap = get_capability(model)
assert cap is not None and cap.evidence.strip()
def test_m3_can_disable_but_m2x_cannot(self):
assert get_capability("MiniMax-M3").can_disable is True
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
def test_unregistered_model_is_unknown(self):
assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability(True, "实测"))
assert get_capability("x-1", table=table) is not None
assert get_capability("x-1") is None # 默认表未被污染
def test_default_capabilities_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
class TestResolveThinking:
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
def test_rule1_none_injects_nothing(self):
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got == {}
@pytest.mark.parametrize("enable", [True, False])
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
with pytest.raises(ValueError, match="register_provider") as exc:
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
assert "extra_body" in str(exc.value)
def test_rule3_unregistered_model_warns_but_passes(self):
messages, sink_id = _warnings()
try:
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
finally:
logger.remove(sink_id)
assert got == {"reasoning_effort": "none"}
assert any("MiniMax-M9" in m for m in messages)
def test_rule4_cannot_disable_raises_with_the_model_name(self):
cap = get_capability("MiniMax-M2.7")
with pytest.raises(ValueError, match="MiniMax-M2.7"):
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
def test_rule4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
assert got == {"reasoning_effort": "medium"}
def test_rule5_normal_path(self):
cap = get_capability("MiniMax-M3")
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
"reasoning_effort": "none"
}
def test_unknown_shape_beats_capability_check(self):
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
cap = ThinkingCapability(can_disable=False, evidence="构造")
with pytest.raises(ValueError, match="register_provider"):
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
+5 -2
View File
@@ -68,10 +68,13 @@ class TestConversions:
_limiter(lease_ttl_s=0)
def test_unknown_source_rejected(self):
from polygateway.errors import GovernanceBackendError
"""未知源是装配缺陷,不是后端故障(issue #7 §3.4)。"""
from polygateway.errors import GatewayUnavailableError, SourceNotConfiguredError
with pytest.raises(GovernanceBackendError):
with pytest.raises(SourceNotConfiguredError) as ei:
_limiter()._cfg("nope")
# 关键: 若归入 scope 级家族,配置写错的任务会永远延期重投、永不进死信
assert not isinstance(ei.value, GatewayUnavailableError)
class TestLuaFidelity:
+3
View File
@@ -209,11 +209,13 @@ class TestObservabilityPassthrough:
raw={},
cached_prompt_tokens=64,
model_reported="MiniMax-Text-01-250321",
reasoning_tokens=7,
)
mw, *_ = _harness([_src("a")], [result])
resp = await mw(_REQ)
assert resp.cached_prompt_tokens == 64
assert resp.model_reported == "MiniMax-Text-01-250321"
assert resp.reasoning_tokens == 7
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
assert resp.model == "m"
@@ -221,6 +223,7 @@ class TestObservabilityPassthrough:
mw, *_ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.cached_prompt_tokens is None and resp.model_reported is None
assert resp.reasoning_tokens is None
class TestRetryAndFailover:
+27 -3
View File
@@ -1,4 +1,4 @@
"""遥测子系统测试: SQLiteRecorder(21 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
"""遥测子系统测试: SQLiteRecorder(22 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
import asyncio
import json
@@ -39,6 +39,7 @@ _EXPECTED_COLUMNS = [
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
]
@@ -102,6 +103,7 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
"cached_prompt_tokens": None,
"model_reported": None,
"sampling": None,
"reasoning_tokens": None,
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -158,6 +160,22 @@ class TestSQLiteRecorder:
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
assert rows["c-none"] is None
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
recorder = SQLiteRecorder(tmp_path / "t.db")
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
recorder.close()
rows = dict(
sqlite3.connect(tmp_path / "t.db")
.execute("SELECT call_id, reasoning_tokens FROM llm_calls")
.fetchall()
)
assert rows["r-some"] == 7
assert rows["r-zero"] == 0 # 上报了且确实没推理
assert rows["r-none"] is None # 本次调用未上报
async def test_sampling_column_round_trips(self, tmp_path):
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
recorder = SQLiteRecorder(tmp_path / "t.db")
@@ -284,6 +302,7 @@ class TestPostgresBackfillDiscipline:
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
]
def _recorder(self, conn):
@@ -384,11 +403,12 @@ class TestEmitterObservabilityFields:
source=_source(),
call_id="cid-1",
latency_ms=42,
response=_resp(cached_prompt_tokens=64, model_reported="m-real"),
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
error=None,
)
assert rec.rows[0]["cached_prompt_tokens"] == 64
assert rec.rows[0]["model_reported"] == "m-real"
assert rec.rows[0]["reasoning_tokens"] == 7
async def test_failed_attempt_has_no_provider_facts(self):
rec = _MemoryRecorder()
@@ -402,16 +422,19 @@ class TestEmitterObservabilityFields:
)
assert rec.rows[0]["cached_prompt_tokens"] is None
assert rec.rows[0]["model_reported"] is None
assert rec.rows[0]["reasoning_tokens"] is None
async def test_cache_hit_replays_the_recorded_values(self):
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec).emit_cache_hit(
request=_REQ, response=_resp(cached_prompt_tokens=64, model_reported="m-real")
request=_REQ,
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
)
row = rec.rows[0]
assert row["cache_hit"] is True
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
assert row["reasoning_tokens"] == 7 # 与 cached 同口径原样回放
async def test_terminal_failure_records_none(self):
rec = _MemoryRecorder()
@@ -420,6 +443,7 @@ class TestEmitterObservabilityFields:
)
assert rec.rows[0]["cached_prompt_tokens"] is None
assert rec.rows[0]["model_reported"] is None
assert rec.rows[0]["reasoning_tokens"] is None
class TestEmitterSamplingColumn:
+4
View File
@@ -54,6 +54,7 @@ class TestLLMResponse:
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
assert resp.cached_prompt_tokens is None
assert resp.model_reported is None
assert resp.reasoning_tokens is None # issue #6: 本次调用未上报
filled = LLMResponse(
"c",
"t",
@@ -68,9 +69,11 @@ class TestLLMResponse:
"cid",
cached_prompt_tokens=0,
model_reported="MiniMax-Text-01-250321",
reasoning_tokens=0,
)
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
assert filled.model_reported == "MiniMax-Text-01-250321"
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
def test_frozen(self):
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
@@ -247,6 +250,7 @@ class TestAuxTypes:
assert s.raw["id"] == "x"
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
assert s.cached_prompt_tokens is None and s.model_reported is None
assert s.reasoning_tokens is None
class TestOcrTypes:
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""机械校验 Gitea Wiki 与源码的可比对事实(签名/导出/字段序/列清单/env 键)。
**只查机械可比对的部分**机制语义行为口径这类需要读懂代码才能判断的断言
不在此列(那部分靠 `解释-治理行为` 的适用性总表做单一事实源 + 人工审查)
设计动机: 2026-08 wiki 做了七轮人工审查,88 条发现里有相当一部分属于
"机械可校验却写错"`gather_bounded(coros, limit)`(实为 keyword-only
`concurrency`)`LLMResponse` 字段表把 `source_name` 排进前 11 遥测列数
写成 20/21(实为 22 )`__version__` 在自称"全集"的页面缺席这类偏差不该
靠人一轮轮追,故收敛为脚本
用法(wiki 是独立仓库,须显式给路径;**不做 skip 静默降级**):
python3 tools/check_wiki_alignment.py --wiki /path/to/PolyGateway.wiki
"""
from __future__ import annotations
import argparse
import dataclasses
import inspect
import re
import sys
from pathlib import Path
import polygateway
from polygateway import EmbeddingClient, GatewayClient, LLMResponse
from polygateway.config import _SOURCE_FIELDS
from polygateway.ocr import OcrClient
from polygateway.providers import register_provider
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
# (符号, 可调用对象) —— 这些的签名必须在 wiki 里逐参数出现
_SIGNATURE_TARGETS = [
("GatewayClient.chat", GatewayClient.chat),
("GatewayClient.from_env", GatewayClient.from_env),
("EmbeddingClient.from_env", EmbeddingClient.from_env),
("EmbeddingClient.embed", EmbeddingClient.embed),
("OcrClient.from_env", OcrClient.from_env),
("OcrClient.recognize_text", OcrClient.recognize_text),
("gather_bounded", polygateway.gather_bounded),
("register_provider", register_provider),
]
def _wiki_text(wiki: Path) -> dict[str, str]:
"""读全部 .md;文件名(不含后缀)→ 正文。"""
pages = {p.stem: p.read_text(encoding="utf-8") for p in sorted(wiki.glob("*.md"))}
if not pages:
raise SystemExit(f"错误: {wiki} 下没有 .md 文件,路径是否指向 wiki 克隆?")
return pages
def check_exports_documented(pages: dict[str, str]) -> list[str]:
"""`__all__` 每一项都得在某页出现过(R1 漏 gather_bounded、R6 漏 __version__)。"""
blob = "\n".join(pages.values())
missing = [name for name in polygateway.__all__ if name not in blob]
return [f"__all__ 的 {name!r} 在全部 wiki 页面中零命中(页首自称『顶层导出全集』)"
for name in missing]
def check_signatures(pages: dict[str, str]) -> list[str]:
"""提到某个公共可调用的那一行,必须列全它的参数名。
只查参数名是否出现,不查顺序与类型后者用自然语言表述合法
历史命中: gather_bounded concurrency 被写成 limit;EmbeddingClient/
OcrClient from_env 用省略号承接 chat 的关键字集合,掩盖了没有 cache=
"""
problems = []
for label, func in _SIGNATURE_TARGETS:
symbol = label.split(".")[-1]
params = [
p.name
for p in inspect.signature(func).parameters.values()
if p.name not in ("self", "cls")
]
# 找出提到该符号的所有行,任一行列全即算通过
lines = [
line
for text in pages.values()
for line in text.splitlines()
if f"`{symbol}`" in line or f"{symbol}(" in line
]
if not lines:
problems.append(f"{label}: wiki 里找不到任何提及")
continue
best_missing: list[str] | None = None
for line in lines:
missing = [
p for p in params
if p not in line and not (_PARAM_ALIASES.get(p, set()) & set(line.split()))
]
if not missing:
best_missing = []
break
if best_missing is None or len(missing) < len(best_missing):
best_missing = missing
if best_missing:
problems.append(
f"{label}: 没有任何一行列全参数,最接近的一行仍缺 {best_missing}"
f"(实际签名 {inspect.signature(func)})"
)
return problems
def check_llmresponse_field_order(pages: dict[str, str]) -> list[str]:
"""字段表出现顺序须与 dataclass 声明顺序一致。
R2 命中: wiki source_name model/provider 并成一行(位置 5),而它实为
12 个字段迁移中的三项目按位置构造 fake, wiki 写会静默错位
"""
page = pages.get("参考-公共API")
if page is None:
return ["缺少 参考-公共API.md"]
# 只在 LLMResponse 小节内找: 别的类型(EmbeddingResponse 等)也有同名字段,
# 全页搜索会命中它们、把顺序判断带偏
start = page.find("## LLMResponse")
if start < 0:
return ["参考-公共API.md 缺少 `## LLMResponse` 小节"]
end = page.find("\n## ", start + 1)
section = page[start : end if end > 0 else len(page)]
declared = [f.name for f in dataclasses.fields(LLMResponse)]
positions = []
for name in declared:
# 取该字段在小节内最早的出现位置(表格首列可能写成 `a / b` 合并形式)
cands = [
section.find(pat)
for pat in (f"| {name} ", f"{name} /", f"/ {name} ", f"| {name}\n")
]
hits = [i for i in cands if i >= 0]
positions.append((name, min(hits) if hits else -1))
documented = [n for n, i in positions if i >= 0]
missing = [n for n, i in positions if i < 0]
problems = [f"LLMResponse 字段 {missing} 未在 参考-公共API 的字段表出现"] if missing else []
ordered = sorted((i, n) for n, i in positions if i >= 0)
actual = [n for _, n in ordered]
expected = [n for n in declared if n in documented]
if actual != expected:
problems.append(
f"LLMResponse 字段表顺序与声明顺序不符\n"
f" wiki 顺序: {actual}\n"
f" 声明顺序: {expected}"
)
return problems
def check_telemetry_columns(pages: dict[str, str]) -> list[str]:
"""遥测列清单与 sqlite 后端的 _COLUMNS 对齐(created_at 由 DDL 生成,单列)。"""
page = pages.get("指南-遥测与成本")
if page is None:
return ["缺少 指南-遥测与成本.md"]
expected = [*TELEMETRY_COLUMNS, "created_at"]
missing = [c for c in expected if c not in page]
problems = [f"遥测列 {missing} 未在 指南-遥测与成本 出现"] if missing else []
# 列数声明: 表 = _COLUMNS + created_at;端口 = _COLUMNS。
# 页内**所有** "N 列" 声明都必须等于真实列数——只查"正确值是否出现"会被
# 漏改的旧数字骗过(它们同时存在时检查照样通过)
table_n, port_n = len(expected), len(TELEMETRY_COLUMNS)
declared_counts = {int(m) for m in re.findall(r"(\d+)\s*列", page)}
if not declared_counts:
problems.append(f"指南-遥测与成本 未声明表列数(应为 {table_n} 列)")
elif declared_counts != {table_n}:
wrong = sorted(declared_counts - {table_n})
problems.append(
f"指南-遥测与成本 的列数声明 {wrong} 与实际 {table_n} 列不符"
f"(端口是 {port_n} 参数,两者差 created_at)"
)
return problems
def check_source_env_fields(pages: dict[str, str]) -> list[str]:
"""`_SOURCE_FIELDS` 的每个 FIELD 段都得在 参考-配置键 出现(R1 命中 EXTRA_BODY)。"""
page = pages.get("参考-配置键")
if page is None:
return ["缺少 参考-配置键.md"]
# 用词边界匹配: `f in page` 会让 EXTRA_BODY 被 EXTRA_BODYY 蒙混过关
missing = [f for f in _SOURCE_FIELDS if not re.search(rf"\b{re.escape(f)}\b", page)]
return [f"源键 FIELD 段 {missing} 未在 参考-配置键 文档化"] if missing else []
_CHECKS = [
("顶层导出覆盖", check_exports_documented),
("公共签名参数", check_signatures),
("LLMResponse 字段序", check_llmresponse_field_order),
("遥测列清单", check_telemetry_columns),
("源 env 键覆盖", check_source_env_fields),
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wiki", required=True, type=Path, help="PolyGateway.wiki 克隆目录")
args = parser.parse_args()
if not args.wiki.is_dir():
raise SystemExit(f"错误: {args.wiki} 不是目录")
pages = _wiki_text(args.wiki)
failed = 0
for label, check in _CHECKS:
problems = check(pages)
if problems:
failed += len(problems)
print(f"{label}")
for p in problems:
print(f" {p}")
else:
print(f"{label}")
print()
if failed:
print(f"{failed} 处机械偏差 —— wiki 与源码不一致")
return 1
print(f"{len(pages)} 页机械校验通过")
return 0
if __name__ == "__main__":
sys.exit(main())