69 Commits

Author SHA1 Message Date
iomgaa a716f12483 Merge branch 'feat/issue-20-reasoning-effort'
推理从「开 / 关」升级为档位(issue #20)。八档 Effort 词汇、源级与请求级两个入口、
能力表按档位登记并经真实网关实测校正、缓存 key 与遥测各加一维。

issue #20 抱怨的三道机制全部恢复: 能力表查得到、装配期报错触发且带可执行替代、
运行期对账成立。核心争议由 T10 的约 500 次真实调用了结——glm-5.3 确实关不掉推理,
issue 里那个 rt≈1.2 是短提示词下的采样噪声。
2026-09-05 12:27:38 -04:00
iomgaa 9021425875 docs: measure the 1.3.3 notes against 1.3.2, not against this branch
The behaviour section described round trips that only ever existed inside the
branch. Verified every claim against `git show 6ec9ec7:<file>` plus a 300-combo
side-by-side run (4 old provider slots x 25 models x three ENABLE_THINKING
states); exactly two differences exist.

- glm-5.3 / glm-5.3-flash / gemini-3.1-pro are newly registered as unable to
  stop reasoning. They were unregistered in 1.3.2, so ENABLE_THINKING=false
  passed; now it fails at assembly. That is the one change that breaks an
  existing config, so it leads the table.
- The openai slot moves from "shape unknown, refuse at assembly" to the standard
  OpenAI shapes in both directions. Neither openai nor anthropic nor google ever
  injected medium; minimax did and still does, byte for byte.
- kimi-k3 was never registered before, so it changes nothing for anyone. Moved
  to new capabilities as a first registration.
- Both keyword and positional ThinkingCapability construction break, not just
  positional; spell out the migration form.

.env.example: name all eight registry slots, and say that "on" injects nothing
on the three slots whose on_base is empty.
2026-09-05 12:25:24 -04:00
iomgaa bb9ef038c7 test: make the assembly guard fail when it forgets the source's fallback
The transport half of the effort_fallback wiring got a test last round; the
assembly half did not. Mutating _guard_thinking's fallback=source.effort_fallback
to a hardcoded "error" leaves the whole suite green, yet a zhipu/glm-5.3 source
carrying REASONING_EFFORT=medium + EFFORT_FALLBACK=nearest goes from assembling
fine to being refused at assembly. Pin it down: from_env must return a client.
2026-09-05 12:20:31 -04:00
iomgaa 85892fb1b5 fix: stop telling people the cache key records the mapped tier
It records the asked-for one. CacheMW sits outside the transport in the
onion, so at lookup time the nearest-mapping has not happened yet and the
applied tier does not exist. Telemetry's success rows do record the mapped
tier, which is where the confusion came from — the warning conflated the
two and would have sent anyone debugging a cache miss the wrong way.

Also repairs the design doc: the 2026-09-05 rollback note had been spliced
into the equivalence table, orphaning its last row, and §3.1 still said
seven tiers after `auto` made it eight.
2026-09-05 11:30:52 -04:00
iomgaa e9607b2f0c docs: cut 1.3.3 notes for the tier work
CHANGELOG gets all five breaking changes, not the one the draft had:
ThinkingCapability's constructor, two ports that grew a parameter with
no default, resolve_thinking's new return type, and ProviderProfile's
single wire field. Behaviour changes get their own section, including
the one that is easy to miss — the openai fallback segment no longer
refuses an unknown shape, so a downstream that parks a foreign model
there and asks for thinking used to fail at assembly and now sends
nothing at all. minimax is called out as the exception it is: the gateway
proved M3 does not think without a parameter, so that segment keeps its
medium and its downstreams see no change this release.

The capability table is reported as it stands — 17 of 24 rows measured,
7 still on documentation, with the reason each one went unmeasured, so
nobody reads "measured" into a row that is a guess. The auto limitation
and its deliberate MiniMax-M3 inconsistency are written down rather than
left for someone to trip over; issue #21 holds the real fix.

ARCHITECTURE had five claims that measurement showed had gone false:
the cache key formula, the field count, the reconcile predicate and its
throttle key, and two field lists. README's FIELD set was missing the
two new keys it calls exhaustive.

docs-convention still opened by announcing a 17-page site that has not
existed since August. It now says what is actually there — one placeholder
page pointing at .env.example, CHANGELOG and the source docstrings — and
says which four files carry the sync gate while the site is down.
2026-09-05 11:22:01 -04:00
iomgaa a194f4326e fix: put the tier back where "on" by itself is not on
The minimax wire lost its tier value on the assumption that these models
reason by default, so injecting nothing still reads as "on". T10 measured
the real gateway and the assumption does not hold: MiniMax-M3 with no
reasoning parameter did not reason in 5 of 5 rounds, while all six
strength values worked. Existing downstreams on ENABLE_THINKING=true
went from reasoning to silently not reasoning, and the capability table
cannot catch it because phase 5 lets auto through unconditionally.

Restore on_base to the old {"reasoning_effort": "medium"} verbatim. This
is a stopgap - it hands the tier choice back to the library, which this
work set out to remove. The real fix is to constrain auto by the
capability table, a public behaviour change tracked as issue #21.

The assertions that said "minimax injects no tier on the on-tier" go
back with it; each carries a note on why it moved twice.
2026-09-05 10:48:08 -04:00
iomgaa f5e6fafe8d test: replace the guessed tier table with what the gateway actually does
T10 经 new-api 中转对 26 个可调用模型逐个实测(约 500 次真实调用),把
DEFAULT_CAPABILITIES 里的文档推定换成实测结论,并把实测本身固化成可复跑、
可只跑单模型的 slow 用例。

三处与文档推定不符:
- kimi-k3 实测可关(关闭档 completion 恒 9 且与 max 档锚点可分),T1 的保守
  登记被推翻,OpenRouter 的 mandatory:false 胜出;
- MiniMax-M3 的 auto 实测不推理(minimax 的"开"是不注入任何参数,而 M3 默认档
  本就不推理),六个强度值反而全部生效且彼此等价,故清单由 (none, auto) 换成
  none + 六个强度值;
- glm-5.3 关不掉推理坐实(请求 none 后 4/5 轮仍观测到推理),issue #20 的核心
  争议就此了结——当时短提示词下的 rt≈1.2 是采样噪声。

新增 kimi-for-coding(现有该型号自己的实测证据,不再走 Phase 3)。

用例带三条判据: 关闭方向要求每轮未观测到推理;短提示词的"关掉了"必须过长
上下文复核(glm-5.3-flash 正是短 5/5 未观测、长上下文 2/3 露馅);上游整片
不回传推理信号时另取无魔数锚点,不许把"没看见"写成"没发生"。另加一道模型
身份核对——该渠道把 glm-5/5.1/5.2 全部路由到 glm-5.3,那三组数据一律作废。

未覆盖 9 个型号(claude 三个 7 天限额用尽、gemini 两个上游报错、gpt-5.4 限流、
glm 三个被路由),evidence 逐条写明原因,不得被后续文档写成"已实测"。

单元测试里三条以 MiniMax-M3 当"纯开关型"样本的用例改用 glm-4.6v——实测后
M3 不再是那个形状,而 glm-4.6v 是全表证据最硬的 (none, auto)。
2026-09-05 10:35:30 -04:00
iomgaa f9b357b9d7 test: give two silent guards something to fail on again
`test_extra_body_overrides_the_profile_slot` guards that a source's
`extra_body` wins over the slot the profile injects. Since T5 made
`enable_thinking=True` mean `auto`, its minimax fixture injected an empty
fragment — swapping the two update calls changed nothing and the whole
suite stayed green, while a real source (zhipu, glm-5.3, tier `low`)
would have shipped `low` instead of the `high` the caller wrote. Move the
fixture onto that combination, where the two sides write the same key.

`emit_attempt`'s docstring promises `reasoning_applies` carries no
default so that a missed argument is a TypeError rather than a silent
`True`; nothing enforced it. Pin the signature the way `record_llm_call`
is already pinned.
2026-09-05 06:54:40 -04:00
iomgaa 4866e6b858 fix: normalise a bare tier string at the fourth door
`resolve_thinking` is exported, and its third argument turned from `bool`
into `Effort` this cycle — so the most natural downstream call now passes
the `"low"` it read out of JSON or config. Every gate inside compares by
identity, and `"none" is Effort.NONE` is always false: the tier was not
rejected, it was silently misjudged. Phase 2 read the on-form for a
request that wanted the off-form, Phase 4 was skipped entirely on a model
that cannot be disabled, and the only visible symptom arrived much later
as an AttributeError from `.value` — an exception that is neither
documented nor one of the four error classes.

Design 4.4 already lists this as the fourth entry a tier can come in
through; this makes the code agree with it.
2026-09-05 06:49:34 -04:00
iomgaa 32b92a8894 docs: count the breaking changes again, and say where the fourth door is 2026-09-05 06:37:57 -04:00
iomgaa e06cd8e8b7 feat: record which tier a call actually ran at
Twenty-five columns and not one of them answered "which tier was this?",
so the question the whole issue exists to settle - does a higher tier buy
anything - had no way to group its data.

The three emit entry points deliberately disagree, the way sampling
already does. A successful attempt records what the transport actually
sent: with EFFORT_FALLBACK=nearest a request for medium goes out as low,
and recomputing here would file the row under a tier that never left the
process. A failed attempt has no response to read, so it falls back to
the requested tier - which is exactly right for the tier errors that are
rejected before any HTTP happens, because the rejected tier is the
signal. Cache hits and terminal failures have no chosen source at all,
so a source-level tier is not a thing they could report.

emit_attempt now demands to be told whether the path reasons at all.
Embedding and OCR share the emitter but never send reasoning parameters;
without the flag a source that mistakenly carries ENABLE_THINKING would
hang a tier on a call that could not possibly have run at one.

The value lands as a plain str. StrEnum is a str subclass and asyncpg
promises nothing about encoding subclasses, and a telemetry write that
fails is only a warning - Postgres would just quietly lose the column.
NULL means nobody declared a tier, which is not the same statement as
'none', and the two must never be folded together.
2026-09-05 05:57:29 -04:00
iomgaa 9832dcee63 docs: name the three doors a tier can enter through, before a fourth appears 2026-09-05 05:12:26 -04:00
iomgaa bd9da4c911 fix: bring a cached tier back as a tier, not as a bare string
Adding applied_effort to LLMResponse put it through the cache round
trip, where JSON stores a StrEnum as its plain value. Rehydrated raw, a
hit would hand downstream a str while the annotation says Effort, and
every `is Effort.LOW` in the library would quietly answer False on the
hit path only -- the same trap thinking_observation already has a
coercion for.

A value outside this version's vocabulary degrades to None rather than
failing the entry: projects sharing one Redis would otherwise keep
invalidating each other's writes over an attribution field, and None is
the honest reading of a tier this version cannot name.
2026-09-05 05:05:28 -04:00
iomgaa 848dc0aa7f feat: wire the tier through the transport and keep each tier's warning distinct
The transport now hands back the tier it actually sent, and that tier
rides TransportResult into LLMResponse. It is not the requested one:
under EFFORT_FALLBACK=nearest a medium request goes out as low, and
telemetry grouping by the requested tier would file the row under a tier
that never left the process.

Reconciliation judges the same tier instead of the old enable_thinking
bool, and the warning throttle keys on it. Keyed on the bool, every tier
of one model shared a single key, so the second contradiction was
silenced for the lifetime of the transport. The predicate is an identity
check against Effort.NONE on purpose -- the member's value is the
non-empty string "none", so any truthiness test would send every strength
tier down the "asked to disable" branch and invert the alarm.
2026-09-05 05:00:29 -04:00
iomgaa 5dfb15e6a2 docs: point the phase 2 row at the predicate it now delegates to 2026-09-05 04:38:10 -04:00
iomgaa d1b3563183 test: golden the key and fingerprint literals for a declared tier
The absent-tier side has had goldens since issue #4; the side that
actually carries a tier had none, so swapping str() for repr() in the
cache key changed the literal without turning the suite red. Pin both
literals. Note the fingerprint's json.dumps emits the same bytes for an
Effort member and its .value, so that pair is indistinguishable by any
test -- the golden pins the literal itself instead.
2026-09-05 04:27:39 -04:00
iomgaa 468af53f51 test: pin the tier fallback and the assembly guard to real behaviour
Both were wired but unwitnessed: hardcoding the transport's fallback, or
blanking the source tier the assembly guard reads, left the whole unit
suite green. Cover them where the value is visible -- the bytes on the
wire for nearest-vs-error, and the assembly-time refusal that must name
low as the executable alternative.
2026-09-05 04:21:32 -04:00
iomgaa 81a901144e fix: judge the phase 2 shape by the tier that was asked for
A provider that registered a disable form but no enable form was told
its shape was unknown and pointed at register_provider -- work it had
already done -- for a request that only ever needed the disable form.
The old bool code took the slot by direction; the tiered rewrite lost
that. Take the relevant field again, and keep "shape unknown" for the
case where both halves are missing, so the "cannot disable" wording
still owns the half-missing case.
2026-09-05 04:14:29 -04:00
iomgaa 1a35d515d9 fix: read a tier the way every config path actually spells it
Both public assembly paths took the tier on trust: a bare "none" from
JSON or a hand-built SourceConfig stayed a str, and `is Effort.NONE`
then read it as a contradiction and crashed on `.value` while wording
the error -- the caller got an AttributeError where a ValueError was
promised, and on the request side that unclassified exception walked
straight through the transport's ThinkingUnsupportedError catch and the
retry classifier.

Normalize at the two entrances instead, matching what the .env path has
always done, and let EFFORT_FALLBACK be spelled with the same freedom as
its neighbour.
2026-09-05 04:07:06 -04:00
iomgaa 701a8a6841 docs: fix the phase 2 predicate that would misdirect a custom provider 2026-09-05 03:49:21 -04:00
iomgaa 78a578bf44 docs: say why the cache key uses the asked-for tier, not the mapped one 2026-09-05 02:45:57 -04:00
iomgaa 33c8e8274b fix: keep a low-tier answer out of the cache slot a max-tier one filled
The per-call reasoning tier never reached the cache key, and the model
fingerprint could not stand in for it: the fingerprint is computed once at
assembly time, so two calls on the same client asking for low and max looked
identical to it. Same messages, different tiers, one shared entry -- the
verbatim replay of issue #4's five seeds all hitting the same response.

Source-level tiers join the fingerprint under the same rule enable_thinking
already follows (appended only when the source takes a position), and the
filter that decides which sources enter the mark set is widened to match --
without that, a source configured with nothing but REASONING_EFFORT would
never reach _fingerprint_mark at all.

None (no opinion) and Effort.NONE (asked not to reason) stay distinct keys.
Sources that opine on neither keep byte-identical keys and fingerprints, so
nothing existing cold-starts.
2026-09-05 02:39:38 -04:00
iomgaa 80a8013642 feat: carry the per-call tier down to the transport that must send it
The Transport port took the request apart into five arguments, so a tier
placed on ChatRequest could never reach _build_payload: the field was set,
read by nobody, and silently ignored - the exact shape of failure that sent
downstream to extra_body in the first place.

complete() now takes reasoning_effort with no default, matching the
TelemetryRecorder convention: a default would turn a missing hand-off into
a silent 'no opinion'. All four fakes move with it, since @runtime_checkable
checks method names and not signatures.

EmbeddingTransport and OcrTransport are deliberately left alone - they have
no reasoning semantics - and a test now holds that line.

_build_payload drops its inline sugar conversion for effective_effort(), so
the guard and the hot path share one judgement, and passes the source's
effort_fallback for the same reason.
2026-09-05 02:28:24 -04:00
iomgaa 1f13eb18ab feat: let one call ask for a different tier than its source defaults to
The three-layer priority (call > source > enable_thinking sugar > silence)
now lives in one pure function, thinking.effective_effort(). The assembly
guard and the request hot path used to each carry their own inline copy of
the sugar conversion; two copies of the same judgement drift into the worst
shape there is - passes at assembly, raises at runtime.

The guard now also honours effort_fallback, so a source that opted into
nearest is no longer sentenced at assembly for a tier it could have mapped.
2026-09-05 02:15:25 -04:00
iomgaa 603a835f60 feat: let a source name its reasoning tier, and say so when it contradicts itself 2026-09-05 01:56:24 -04:00
iomgaa ed563b9ca0 docs: let the tie rule stand over the worked example that contradicted it 2026-09-05 01:43:22 -04:00
iomgaa a1c4273a8b feat: refuse an impossible tier with the cheapest one that model does have
resolve_thinking now takes an Effort instead of a tri-state bool, and the
four gates become five. The new one sits ahead of the generic tier check
on purpose: asking for `none` on GLM-5.3 used to fall through to "none is
not supported, pick low/high/max", which loses both the fact that the
model cannot stop reasoning and the one tier the caller could switch to
right now. Without that alternative, downstream goes looking for
extra_body — which is how issue #20 happened in the first place.

The return type is a ThinkingResolution rather than the payload alone.
Under fallback="nearest" the tier that goes out is not the tier that was
asked for, and telemetry has to record the one that ran, or task 10 files
a call under a tier it never used. Ties in that mapping go to the weaker
side: a silent medium -> max is a multiple of the bill, and the library
does not raise a caller's price on its own.

Two readings the design left implicit, both settled the way its own
compatibility promise requires:

- `auto` is exempt from the tier list. It means "on, no tier named",
  which in the body is the absence of the effort key, not a value of it.
  Checking it against the list would break every existing source that
  sets ENABLE_THINKING=true against deepseek-v4 or glm-5.3.
- `none` is never a mapping target. Turning "think less" into "do not
  think" reverses the decision instead of cheapening it; a switch-only
  model maps to `auto` and a model that only has `none` still errors.

Both call sites convert enable_thinking in place for now; task 5 folds
that into effective_effort along with the source- and call-level tiers.
2026-09-05 01:36:30 -04:00
iomgaa 84230673b9 fix: import the ThinkingWire that __all__ already promised
The previous commit added the name to __all__ but never bound it, so
`from polygateway import ThinkingWire` and `import *` both raised while
the whole suite stayed green — the export test names symbols one by one,
and nobody thought to add the new one.

The guard is now the invariant rather than a longer list: every name in
__all__ must be an attribute of the package.
2026-09-05 01:17:12 -04:00
iomgaa 3cb5331950 docs: correct the equivalence claim T2 disproved 2026-09-05 00:48:01 -04:00
iomgaa 7fabc792b2 feat: give zhipu, moonshot, anthropic and google a wire of their own
Eight segments now, and each one holds a ThinkingWire instead of two
fixed fragments: off, on_base, and the key a tier gets written to. The
two fragments could not say "on, at this depth", which is what every
current generation model wants.

Two deliberate behaviour changes fall out of it. The openai segment stops
reporting its shape as unknown — reasoning_effort is OpenAI's own field,
not a vendor dialect, so a compatible endpoint behind the gateway takes
it. And minimax's on-tier stops carrying a hardcoded medium: that was the
library picking a price for the caller, and medium is not even a tier GLM,
kimi or deepseek serve.

The issue #5 guards stay; their sample moves from "the openai segment" to
an explicitly registered unknown one, which is what they always meant to test.
2026-09-05 00:42:58 -04:00
iomgaa 2a50ddcf12 refactor: make capability a tier list, since "can it be off" is one entry
The boolean could say a model reasons or does not. It could not say what
GLM-5.3 and Gemini 3 Pro actually do: refuse to stop reasoning while
still letting you ask for less. So capability becomes the list of tiers a
model serves, and `none`'s presence in it is what "can_disable" now reads.

Effort carries `auto` alongside the strength tiers. Nine of the models on
our gateway are pure switches with no tier to name, and without `auto`
they would have to borrow a strength tier to mean "on" — which is the
exact bug this work exists to remove.

Tiers land as documented guesses from four registries that agree; every
entry says so in its evidence, and task 10 replaces them with measurements.
2026-09-05 00:31:28 -04:00
iomgaa de261e485d docs: fix the three places the plan could not actually execute
Codex found the per-call tier never reaches the transport: the protocol
takes five unpacked arguments, not the request, so a field on ChatRequest
goes nowhere. That is now its own step, fakes included.

It also found the mapped tier would be lost — resolve_thinking returned
only the payload, so telemetry would file a mapped call under a tier it
never ran at, which is exactly the grouping task 10 depends on.
2026-09-05 00:21:29 -04:00
iomgaa e01420178f docs: register the design and plan in the wiki, with what got rejected
The entity pages carry the rejected alternatives and why, so the next
reader does not have to reconstruct the argument: minimal patch cannot
fill a tier that does not exist, cherry's wire DSL buys four endpoint
protocols we do not have, and default_effort lost its only consumer the
moment enable_thinking=True became Effort.AUTO.
2026-09-05 00:12:55 -04:00
iomgaa abeb09f588 docs: plan the tier work as ten steps that each stand on their own
Ordered so the two type changes land first and everything else consumes
them: capability and wire in parallel, then the five gates, then the two
entry points, then cache key and telemetry, then the transport.

Task 10 exists because the human settled that the capability table is
measured through new-api, not read off a vendor page. Task 1 lands the
documented guess; task 10 replaces it with what the gateway does.
2026-09-05 00:06:25 -04:00
iomgaa 862fc3f5a9 docs: say which way the capability table leans when a source disagrees 2026-09-04 23:57:55 -04:00
iomgaa c920ab4b83 docs: record the human approval and the tier-mapping call it settled
Codex argued `effort_fallback` had no verifiable consumer and could wait.
The human overruled that: swapping models is routine, and between "it
stops booting" and "it silently costs more" the caller deserves a middle
option it selects on purpose. So the mapping ships with this round.
2026-09-04 23:51:25 -04:00
iomgaa 5577812a16 docs: design reasoning effort as a tier the boolean cannot express
issue #20 asks for one zhipu profile. Adding it does not fix what the
issue describes: GLM-5.3 mandates thinking (three sources agree, the
vendor included), so `none` is an undefined value we were sending, and
`medium` — the tier our minimax profile hardcodes — does not exist on
GLM, kimi or deepseek at all.

So the gap is the type, not the table. Capability becomes a tier list
where `none`'s presence answers "can it be turned off", and refusal
carries the cheapest tier that model does support — a refusal with no
way forward is what sent the caller to extra_body in the first place.

Reviewed by Codex, which caught two claims that were wrong: source-level
extra_body and enable_thinking already reach the cache key through the
model fingerprint, and the three reference projects are not in the
workspace, so "no callers" was a grep against absent directories.
2026-09-04 23:43:06 -04:00
iomgaa 6ec9ec7056 Merge branch 'fix/issue-18-pg-test-isolation'
issue #18: the retention script can be told which table it may delete
from, and the Postgres tests moved off the table three migration
projects also write to.

The assertion that was failing intermittently compared row counts on a
shared table before and after the run. It could go red because someone
else wrote, and green because an outside insert cancelled out a wrong
delete. That property now belongs to the database: the tests run as a
role that owns its scratch table and holds no grant on the shared one.
2026-08-28 05:46:20 -04:00
iomgaa 5255f68900 chore: date 1.3.2 to the day it actually ships 2026-08-28 05:46:20 -04:00
iomgaa 58c4af28ea fix: refuse the sandbox rather than quietly running it as the superuser
Both reviews landed on the same line independently. _as_role swaps the
credentials in the DSN with a regex, and when the pattern does not match
it returned the string unchanged. Two shapes miss it: no inline
credentials, and a unix socket URL. Either one is a legal DSN.

What that costs is not a broken test. The sandbox builds, every
assertion still passes, and bare_dsn is now the admin connection, so the
worst-case case runs the real script with --apply as a superuser against
the shared table. The verifier ran that command as a dry run to see what
it would have done: target public.llm_calls, 11 rows to delete. The case
would still have gone red on the exit code, after the rows were gone.

It raises now. There is also a second check that connects and compares
current_user, because a successful string substitution is not the same
as connecting as that role -- PGUSER and friends still override. The
whole design rests on that connection having no grant on the shared
table; a string comparison is too thin a thing to rest it on.

That check has to stay inside the try. Past it the cleanup statements
have already been merged into the fixture-level stack, and unwinding
again runs DROP OWNED BY twice, which has no IF EXISTS.

The catalog probe took any SQL and ran it on the admin connection. The
design claims withholding the DSN makes the boundary structural; that
was only true of the connection string, not of the capability. It takes
SELECT now.

--table's schema half is restricted to plain identifiers. Not a
security fix, since the name goes through a parameter and _quote: the
help text says complex identifiers are unsupported and the code was
accepting them anyway.
2026-08-26 11:59:51 -04:00
iomgaa bc0fcc4719 docs: cut 1.3.2, and say plainly that the wheel did not change
tools/ and tests/ are not in the package, so this release ships library
code identical to 1.3.1 byte for byte. Anyone who only uses the library
can skip it. Saying so up front is better than letting someone diff the
wheel and wonder what they missed.

What is in it: the retention script can now be told which table it may
delete from, and the Postgres tests no longer touch the table three
migration projects also write to.

The --table entry documents the failure it prevents rather than just
the flag. search_path starts with "$user", so the same command run as a
different role can resolve to a different table, and the script's own
printout of what it resolved lands in the same run as the DELETE.
2026-08-26 10:55:15 -04:00
iomgaa ea9e5062e8 fix: restore the wiki alignment check, which no longer imported
The telemetry column list was renamed from _COLUMNS to COLUMNS at some
point and this tool was never updated, so make wiki-check has been dying
on an ImportError rather than checking anything. One line.

It still reports every page as missing, but that is the documentation
site being taken down in August, not a fault in the check.
2026-08-26 10:55:15 -04:00
iomgaa c8746b1ca1 test: move the Postgres tests off the table other projects write to
Seven cases wrote straight into the shared table and told their rows
apart by a call_id prefix. Reading was never the problem; the prefix
did that correctly, and it was built for concurrent runs. What it could
not do was stop those writes and deletes from moving a row count that
another test was watching, which is how issue #18 turned red.

They now write into sandbox schemas, which also ends the orphan rows a
killed run used to leave in there. Six fixtures collapse into factory
calls; what they yield is unchanged, so the cases that consume them did
not have to be touched, which is what makes them worth anything as a
check on the move.

Two of the seven kept something. The pool footprint case needs a unique
application_name, since connections are an instance-wide resource that
schema isolation does not reach, so it generates its own uuid instead
of borrowing the run prefix. And the frozen-columns case was querying
information_schema without a schema filter, so any leftover table of
the same name anywhere in the database could fail it: the file already
knew this, in a comment explaining why another fixture cleans up so
carefully. It now filters, and gets checked against a leftover table
planted on purpose.

The gate that keeps the literal out of tests/ is a smoke alarm, not
proof. Concatenation and parameterised queries walk straight past it.
The isolation is the factory withholding the admin connection and the
script running as a role with no grant.
2026-08-26 10:47:52 -04:00
iomgaa 503c06327e feat: let the retention script be told which table it may delete from
Until now the target came from whatever search_path resolved to. The
script printed what it found, but that print and the DELETE happen in
the same run with nobody in between, so it only ever helped the person
who ran a dry-run first. Swap the role that runs it and "$user" can
resolve somewhere else entirely.

--table takes the whole qualified name and resolves it directly. The
table half has to be llm_calls: a version that accepts any name turns
one typo into a general purpose row deleter, and any table with a
created_at and a tenant_id would go through the same batched DELETE
without complaint.

The tests that run it now run as a role that owns its own scratch table
and holds nothing on the shared one, so the row-count snapshot could
go. What replaced it is a case that lets the script fall through to the
shared table on purpose and asserts it exits 2 having deleted nothing.
That one has no red-first path, since making it red means running it as
the superuser, which is the thing being prevented; the finding's probe
covers it instead.

Five of the new usage tests passed before the flag existed, because
argparse rejects an unknown --table with exit 1 and the word --table in
stderr, which is exactly what they asserted. They now also assert the
error is not "unrecognized", which is the difference between testing
the validation and testing argparse.
2026-08-26 10:38:21 -04:00
iomgaa 064f22a0a0 test: build the sandbox factory the PG tests will run inside
Seven copies of "create a schema, hang it off search_path, drop it in
teardown" were spread across two files, each with its own cleanup. Any
one of them written wrong leaves the residue on a database shared with
real batch runs. This is one implementation, and it makes "the test
cannot reach the admin connection" a structural fact rather than a note
in a docstring.

Three role modes cover every fixture that exists today: none for plain
schema isolation, owner for the retention script's own runs, grantee
for the least-privilege deployment cases. Owner runs its DDL as itself
so it ends up owning the table; grantee is the opposite, since that
case only means anything when someone else built it.

The schema and the role deliberately get different prefixes. Give them
the same name and "$user" resolves to the sandbox, which hides the
shared table and quietly turns the worst-case test into a test of
nothing.

Writing it also turned up a bug in my first version: rolling back a
failed sandbox unwound the whole stack, so an earlier sandbox in the
same test lost its role mid-use. The test for it fails with a password
authentication error, which is what that looks like from the outside.
Each call now unwinds only what it created, and cleanup tries every
statement before raising, since one failure stranding the rest means
global roles left behind by hand.
2026-08-26 08:14:23 -04:00
iomgaa ea791c9f30 docs: order the issue #18 work so nothing deletes the shared table
The plan's one irreversible risk is the worst-case test itself. It
deliberately lets the script fall through to the shared table, and the
account in .env is a superuser, so running it before the sandbox role
exists would delete every expired row in there. All eleven rows on that
table predate any cutoff the tests use.

That forces the order: factory, then the retention tests move onto an
owner role, and only then does the case get written. Review caught that
the original order also made the --table integration cases impossible
to fail first, since the tool would already be implemented by the time
they were written. Same fix resolves both.

The worst-case case has no red-first path at all. Turning it red means
running it as the superuser, which is the thing being prevented, so its
evidence is the probe in the finding instead, and the plan says so
rather than calling it verified.

One acceptance criterion in the design turned out to be unrunnable: the
hint line only prints on the Postgres branch, so no unit test that
never connects can assert it. Corrected in place.
2026-08-26 07:32:20 -04:00
iomgaa 965938230a docs: design issue #18 around what a safety net can actually prove
The failing assertion was never testing the script. It compared the row
count of a table three other projects also write to, before and after
the run, and the failure it reported (61 rows became 12) landed while
the script under test was demonstrably confined to its own schema.

A row count cannot carry the property that assertion stood in for. It
goes red when anyone else writes, and green when an outside insert
happens to cancel out a wrong delete. The second half is the one that
was guarding against a dropped audit table.

So the property moves to where the database enforces it: the script
runs as a role that owns its scratch table and holds no grant at all on
the shared one. Falling back to public stops being something a later
assertion might notice and becomes permission denied. Measured, along
with the rest of the Postgres semantics this rests on.

The tool grows --table so the target stops being whatever search_path
resolves to. The table half is pinned to llm_calls: without that, one
typo turns a telemetry cleaner into a general purpose row deleter.

Codex raised six problems and all are folded in. The one place this
still disagrees with it is recorded with the reason.
2026-08-26 05:39:33 -04:00
iomgaa 2bff962e48 Merge branch 'feat/issue-16-17-thinking-observability'
Whether a call actually reasoned is now a first-class return value
(issue #16 + #17). The issues blamed MiniMax-M3 for no longer
reasoning; probing the live gateway showed the opposite. M3 reasons
fine — 124 characters of it over SSE — and what changed is that the
MiniMax route stopped reporting completion_tokens_details while qwen
and deepseek still do. The library had staked the whole question on
that one field, so it held 185 characters of reasoning prose and
reported no reasoning.

ThinkingObservation says observed, absent, or unknown, and unknown
means the call left no signal rather than that nothing happened. The
verdict is reconciled against the capability table on every call, so a
declaration going stale becomes a warning instead of a silent illusion
— the M3 evidence had sat unchecked for twenty-three days. It lands in
telemetry too, because this surfaced only when someone ran a suite that
is excluded by default and had not run in eighteen days.
2026-08-26 04:36:05 -04:00
iomgaa 6e205e9382 docs: retire the criterion this version disproved, everywhere it survived
The reasoning_tokens docstring was still teaching downstream to treat
None or 0 as no reasoning. The changelog and the schema page had both
been corrected; the docstring had not, and it is the copy that ships in
the wheel and shows up on hover. Someone writing a report from it would
have counted every real MiniMax reasoning call as not reasoning, which
is issue #16 all over again with the tests green.

The original wording stays, since reading pre-1.3.1 rows still needs
it. What follows it now says when it expired and what to read instead.

Two more places had drifted the same way: the changelog and the
architecture doc described the throttle and the cache fallback as they
were before this review, which is to say as the opposite of what the
code now does.

The claim that the two throttle sets would suppress each other does not
survive checking, as the mutation testing showed: their key spaces do
not overlap. Keeping them apart is still right, but for the honest
reason, which is that the two warnings have unrelated lifetimes.
2026-08-26 02:40:22 -04:00
iomgaa 1307a02b92 fix: close the failure modes review found in the new code
Three of them were the same shape as the bug this branch exists to fix:
something goes wrong, the library swallows it, and the caller is left
with a number that means the opposite of what happened.

The throttle key had no source in it. Five sources on one model is the
normal case here, so the first one to break would warn once and silence
the other four for the life of the process, and the message never said
which gateway to look at.

An unknown verdict in a cached entry threw away the whole response. The
rehydrator tolerates unknown fields but not unknown values of a known
field, so two library versions sharing a Redis would each invalidate
the other's entries: halved hit rate, and the only log line says the
cache rebuild failed. A purely observational field should not be able
to void a response whose content is intact.

Normalising for telemetry now degrades instead of raising, both for a
bare string and for a value outside the domain. Either one used to
reach the same except and cost the whole row, which is exactly how
1.3.0 lost nineteen calls without anyone noticing.
2026-08-26 02:37:24 -04:00
iomgaa c0b544d233 chore: cut 1.3.1 2026-08-26 01:07:05 -04:00
iomgaa 578a144231 docs: sync the field counts and module map to 1.3.1
The telemetry field count is taken from inspect.signature, not from
memory, because that is the one the release checklist keeps catching.
llm-calls.md said 22 and was two rounds stale; fixing the title alone
would have left the table contradicting it, so tenant_id and meta are
documented too.

The production template needed no new column — it derives them with
LIKE. What it gained is an assertion that it must keep deriving them
and must not inline a column name, which is the drift that could
actually happen.

The changelog leads with the three breaking items. A patch number
carries no warning by design, so the entry has to.
2026-08-26 01:03:25 -04:00
iomgaa 1921a067a1 test: judge reasoning by what the library actually observed
The four cases were red because the criterion could not see the
evidence. reasoning_tokens has been None on this route ever since
MiniMax stopped reporting completion_tokens_details, while the same
call carried 185 characters of reasoning prose the assertions never
looked at.

L5 asserted something that cannot happen. M3 returns neither prose nor
usage detail over the plain endpoint, so demanding that the
non-streaming path observe reasoning could never pass. It now asserts
what is true and worth holding: the prompt_tokens anchor still
separates the two directions, so the parameter did reach the model, and
the verdict is not ABSENT, so the library marked the gap honestly
instead of dressing it up as no reasoning.

_ON_MIN_COMPLETION is gone. The two directions overlap in output length
— 46 at most disabled, 13 at least enabled — so that fallback drew a
line through noise and only made the criterion look defended.
2026-08-26 01:00:33 -04:00
iomgaa bd95a05c30 docs: retract a plan item that was wrong and would have broken deploys
The README's production template does not hand-write its columns; it
derives them with LIKE from the seed table, and the prose right above it
says so. Telling an executor to add a column there would have made
Postgres reject a duplicate, turned TestProductionTemplate red, and
broken deployment for anyone following it.

The claim came from another task's report and went into the plan without
opening the README. A finding relayed across tasks is a lead to verify,
not a fact. What replaces it is a shape assertion — the template must
derive via LIKE and must not inline any column name — which pins the
real risk of someone copying columns in later.

Also adds the Gitea wiki sync the plan had missed: docs-convention makes
a version bump commit illegal on its own.
2026-08-26 00:57:22 -04:00
iomgaa 758229bda9 docs: fold what implementation found back into the plan
The README carries a hand-written production DDL template that no test
ever compares against COLUMNS, so it can fall a column behind and stay
green. Downstream deploying from it would get a table without the new
column and the library would silently trim it — the same silence this
issue exists to remove. Task 9 now fixes the template and adds the
same-source assertion.

Also records two things the implementation disproved: caplog cannot see
loguru output, and reconcile_thinking has to be defined after the
dataclass it annotates, since this module evaluates annotations eagerly.
And the column-count table was incomplete — six more spots go red.
2026-08-26 00:32:41 -04:00
iomgaa 56acb8f3ac feat: record the reasoning verdict in telemetry
This issue surfaced only because someone ran a slow suite that is
excluded by default and had not been run for eighteen days. As a column
it becomes a query: which model stopped being observable, and when.

The emitter unwraps the enum to a plain str at the single _record exit.
asyncpg makes no promise about encoding a str subclass, and a telemetry
write that fails is downgraded to one warning — it would not crash, it
would just quietly cost the Postgres path a column. Normalising at the
emitter follows what tenant_id, meta and sampling already do.

The column is appended last in COLUMNS and in both DDLs. An existing
table can only take ALTER at the end, so putting it anywhere else
forks the physical column order between a freshly built database and a
backfilled one.
2026-08-26 00:29:26 -04:00
iomgaa ab1c47ebcc fix: revive the reasoning verdict as an enum, not a bare string
asdict keeps the enum and json.dumps writes it as a string because
StrEnum is a str subclass, but nothing turns it back on the way in, so
a cache hit returned a plain str where the annotation promised an enum.
Verified end to end rather than assumed from the subclass relation.

A value outside the domain now raises inside the existing guard and the
call falls back to source, which is the right direction for a poisoned
or stale cache entry. Entries written before this column existed still
replay: the guard checks for the key first, and a test pins that, since
turning it into an unconditional conversion would quietly turn every
pre-upgrade entry into a permanent miss.
2026-08-26 00:26:36 -04:00
iomgaa 20a4a9ae47 feat: warn when the capability table and reality disagree
The M3 evidence sat at 08-02 for twenty-three days while nobody could
tell whether it still held. A declaration that goes stale in silence is
the failure this issue is really about, so the library now compares
what it declared against what it just observed and says so when the two
part ways.

Judgement is separated from logging: reconcile_thinking returns the
warning text, so tests assert on the text instead of parsing logs.
Two cases that look alike are kept apart — a model whose capability is
registered gets a drift warning quoting its evidence, an unregistered
one is never told the table said anything, because it never did.

False x UNKNOWN stays silent on purpose. UNKNOWN cannot falsify
anything, and warning on it would fire on every disabled call M3 makes
over the plain endpoint. A warning that always fires is not a warning.
2026-08-26 00:23:57 -04:00
iomgaa 3e869b9b39 docs: refresh the M3 capability evidence with the 08-25 retest
can_disable stays true — reasoning_effort=none still lands prompt 194,
completion 3, no prose. What the retest added are two limits worth
recording: the verdict is unobservable on the non-streaming path, where
reasoning is billed but neither prose nor usage detail comes back, and
enable_thinking / thinking:{enabled} remain inert on this model.

No behaviour changed, so there is no failing test to show first. The
evidence for a declaration that still holds is the retest itself, not
a unit test the library could write about its own claim.
2026-08-26 00:06:22 -04:00
iomgaa 8c5c23ae72 feat: carry the reasoning verdict through to LLMResponse
Both assembly paths fill it, streaming and non-streaming alike. Filling
only one is exactly the divergence this issue exposed: M3 returns
reasoning prose over SSE and nothing at all over the plain endpoint, so
a verdict computed on one path says nothing about the other.

The field defaults to UNKNOWN on both TransportResult and LLMResponse.
A transport that does not judge should not get to declare absence on
the provider's behalf, and a default that stays silent is the only one
that cannot lie.
2026-08-26 00:03:28 -04:00
iomgaa 59d2e442e6 style: drop the redundant parens ruff format flagged 2026-08-26 00:00:29 -04:00
iomgaa a2b319f250 docs: correct how the recorders actually take their fields
Both recorders are (self, **fields), not explicit parameter lists, so a
new column needs no signature change on them — COLUMNS plus an emitter
that passes it is enough. The port Protocol stays explicit because that
is where the emitter's contract and the freeze test anchor.
2026-08-25 23:54:43 -04:00
iomgaa 7622eb0402 refactor: give reasoning decisions their own module
providers.py had been holding two jobs: the registry of what each
provider looks like, and the decisions made from those declarations.
Adding response-side judgement would have made it the module for
everything about reasoning, so the decisions move to thinking.py and
the registry keeps only profiles and their lookup.

Moving a module breaks any deep-path import of what moved, so the six
public symbols are promoted to the package root at the same time. The
top level is this library's stated API surface; giving downstream a
stable name to import is what makes the next reorganisation harmless.
observe_thinking stays unexported — downstream reads the verdict off
LLMResponse, and exporting it would be a permanent promise for nothing.
2026-08-25 23:48:45 -04:00
iomgaa e90bb3d6a4 feat: judge whether reasoning actually happened from multiple signals
reasoning_tokens=None has been carrying two meanings at once, no
reasoning and no report, and the library resolved the ambiguity by
quietly claiming the first. ThinkingObservation splits them: UNKNOWN
says the call left no signal, ABSENT says the provider reported zero.

The verdict ranks evidence by hardness. Reasoning prose is the fact
itself; reasoning_tokens is a report about the fact, so a missing
report cannot overrule prose that is right there. The prose check
strips first, since a gateway that returns whitespace is not evidence.

The enum lives in types.py, not in the new thinking.py, because
LLMResponse is typed on it and the innermost layer must not import a
decision module.
2026-08-25 23:40:39 -04:00
iomgaa 85bcc23a6b docs: split the release task at a human confirmation gate
Everything up to and including the full slow suite runs on the branch
without asking. Merging to main, pushing a tag, and uploading to the
registry cannot be taken back, and a registry version number cannot be
reused, so those wait for an explicit yes.

Also settles three things an executor would have tripped on: the
_AttemptUsage field is typed as the enum with .value applied only at
the recorder boundary, __all__ is not in strict alphabetical order, and
the port signature test freezes two params rather than the full list.
2026-08-25 23:35:59 -04:00
iomgaa 626bbdcc83 docs: plan the reasoning observability work as ten commits
Each task carries its own failing-then-passing evidence and a command
whose output decides whether it is done. Two traps are called out where
an executor would otherwise walk into them: the enum has to live in
types.py or import-linter rejects the layering, and the 24 in
test_telemetry.py line 1787 counts OCR placeholder characters, not
telemetry columns.
2026-08-25 23:27:36 -04:00
iomgaa 5cf225481c docs: fix the four blockers Codex found in the design
The enum belonged in types.py all along: making LLMResponse field-typed
on a symbol defined in thinking.py would have had the innermost layer
import a decision module, and import-linter would have caught it only
after the code was written.

The 4.1 table claimed reconciliation could still catch a failed disable
while section 5 said UNKNOWN never speaks. UNKNOWN has no falsifying
power; the guarantee only covers observable paths, and the doc now says
so instead of pretending otherwise.

Section 12 was written against a misreading: _record already is the
single helper the ironclad rule asks for, so there was no debt to
decline. Landing sites had missed ports.py, whose record_llm_call
freezes 24 explicit params with no defaults, and cache.py, where
_rehydrate revives the enum as a bare string.
2026-08-25 22:16:41 -04:00
iomgaa e03b2afd8c docs: record the human call to ship this as 1.3.1
The design argued for 1.4.0 because a broken deep-path import hidden
behind a patch bump is a debt handed to downstream. The call is 1.3.1.
Since the version number no longer carries the warning, the CHANGELOG
has to: breaking items and their fixes go first in the entry, following
the 1.3.0 read-this-first form.
2026-08-25 22:08:01 -04:00
iomgaa 37b4a557c2 docs: disprove the issue #16/#17 diagnosis with live gateway probes
The four red e2e cases were blamed on MiniMax-M3 no longer reasoning.
Raw gateway probes show the opposite: M3 reasons fine (124 chars of
reasoning_content, prompt 194 to 216, completion 3 to 60). What changed
is that the MiniMax route stopped returning completion_tokens_details,
while qwen and deepseek still do on the same gateway and key. The
library already holds 185 chars of proof in LLMResponse.thinking and
never feeds it into any verdict.

The design turns that verdict into a first-class return value judged
from multiple signals, says UNKNOWN when a single response cannot tell,
and reconciles it against the capability table so a stale declaration
becomes a warning instead of a silent illusion.
2026-08-25 22:02:03 -04:00
60 changed files with 8573 additions and 849 deletions
+15 -2
View File
@@ -2,7 +2,8 @@
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══
# PROVIDER 必须是注册表键(qwen/deepseek/openai,或 register_provider 注册后经 registry 传入)。
# PROVIDER 必须是注册表键(八段: qwen/deepseek/zhipu/moonshot/minimax/openai/anthropic/google,
# 或 register_provider 注册后经 registry 传入)。
# 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
LLM__QWEN__1__BASE_URL=
LLM__QWEN__1__API_KEY=
@@ -15,7 +16,19 @@ LLM__QWEN__1__TIMEOUT_S=120
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是
# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错
# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
+197
View File
@@ -1,5 +1,202 @@
# Changelog
## 1.3.3(2026-09-05)
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
**版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。** patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。
### 请先读这一条(一):五处破坏性变更
| # | 位置 | 变更 | 谁会当场断 |
|---|---|---|---|
| 1 | `ThinkingCapability` | 构造签名 `can_disable: bool``supported_efforts: tuple[Effort, ...]` | 自建能力表的调用方(**关键字与位置两种构造都断**) |
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort`(25 → 26 参) | 任何自建 recorder 实现 |
| 4 | `thinking.resolve_thinking()` | 第三参数由 `bool` 换成 `Effort`,**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 直调它的读侧代码一律断 |
| 5 | `providers.ProviderProfile` | 两个字段 `thinking_on` / `thinking_off` → 单字段 `thinking: ThinkingWire` | 自建 profile 的调用方 |
第 1 条的 `can_disable` **保留为只读派生属性**(`Effort.NONE in supported_efforts`),只读它的代码一行不用改;**构造则两种写法都断**:
| 1.3.2 的写法 | 升级后 |
|---|---|
| `ThinkingCapability(can_disable=True, evidence="…")`(库自己那张表用的就是它) | `TypeError: ... got an unexpected keyword argument 'can_disable'` |
| `ThinkingCapability(True, "…")` | `TypeError: 'bool' object is not iterable`——断在 `__post_init__` 的去重校验里,错误信息看不出真实原因 |
| 迁移写法 | `ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")` |
第 2、3 条按这两个端口的既有纪律**不设默认值**:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 `ThinkingResolution(payload, applied_effort)`——原来那个 mapping 现在是 `.payload`,多出来的 `.applied_effort` 是开了 `nearest` 映射后**真正发出去**的那一档。
### 请先读这一条(二):不改一行代码也会变的四条行为
| # | 变更 | 影响 |
|---|---|---|
| 1 | `glm-5.3` / `glm-5.3-flash` / `gemini-3.1-pro` **首次进入能力表**,且三者都登记为**关不掉推理** | **本版唯一会打断存量配置的一条。** 1.3.2 里这三个型号未登记,给它们配 `ENABLE_THINKING=false` 会按 provider 形态尽力注入并**放行**(只发一条 warning);本版在**装配期**抛 `ThinkingUnsupportedError`。并排实测:`deepseek/glm-5.3 + ENABLE_THINKING=false` 在 1.3.2 返回 `{"thinking": {"type": "disabled"}}`,在本版当场报错 |
| 2 | `openai` 段的**开启**方向由「形态未知即装配期报错」放宽为 `on_base={}` | 把任意兼容厂商挂在 `openai` 段下并配 `ENABLE_THINKING=true` 的下游:1.3.2 在装配期报错,本版放行且**一个字节都不注入**——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」) |
| 3 | `openai` 段的**关闭**方向由「形态未知即装配期报错」放宽为 `{"reasoning_effort": "none"}` | 同上但配 `ENABLE_THINKING=false` 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 `reasoning_effort` 是 OpenAI **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 |
| 4 | 缓存 key 加入 `reasoning_effort` | 只有**新配** `REASONING_EFFORT` 的源冷启动一次;只配 `ENABLE_THINKING` 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对) |
第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 `extra_body` 那条绕过库的路。
**`qwen` / `deepseek` / `minimax` 三段的注入形态逐字未变。** 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × `ENABLE_THINKING` 三态 = 300 种组合)显示,本版与 1.3.2 的差异**只有上表第 1、2、3 条**。`minimax` 的「开」尤其值得点名:它维持 `{"reasoning_effort": "medium"}` 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时**不推理**(5/5 轮),把它改成「不注入即为开」会让存量 `ENABLE_THINKING=true` 的调用静默停止推理。
### 新增能力
| 新增 | 说明 |
|---|---|
| 八档 `Effort`:`none` / `auto` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` | 封闭词汇,取四家参考实现共同收敛的那一套。`none` = 要求不推理(与「不表态」是两回事),`auto` = 要求推理但不指定强度 |
| `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT` | 源级默认档。`ENABLE_THINKING` 保留,降为它的语法糖(`true``auto``false``none`、缺省 ≡ 不表态);两键语义矛盾(如 `true` + `none`)在**装配期**报错,不做「后者赢」的静默兜底 |
| `{SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK` | `error`(缺省,报错)或 `nearest`(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 `medium → max` 在部分模型上是数倍账单 |
| `chat(reasoning_effort=...)` | 请求级覆盖,优先级高于源级;裸字符串会在入口归一 |
| `LLMResponse.applied_effort` | 本次**实际**跑在哪一档(开了 `nearest` 时与请求档分叉)。字段追加在末尾,既有字段只增不改名 |
| 四个新 provider 段 `zhipu` / `moonshot` / `anthropic` / `google` | 连同 1.3.2 已有的 `qwen` / `deepseek` / `minimax` / `openai` 共**八段**。四段都是新增,不改变任何存量配置的行为 |
| 能力表由 **5 条扩到 24 条** | 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款 |
| `kimi-k3` **首次登记**为可关闭 | 它在 1.3.2 未登记(配 `false` 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 `none` 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 `mandatory:false` 是对的,官方档位表没列 `none` 只是没列 |
| 包根新增导出 `Effort` / `EFFORT_ORDER` / `ThinkingWire` / `ThinkingResolution` | 深路径 import 会被内部重组打断,一律从 `polygateway` 包根取 |
档位不支持时**报错必带可执行替代**:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 `extra_body`——而那正是 issue #20 的成因。
### 遥测:第 26 个 INSERT 字段 `reasoning_effort`
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,`NULL` 表示调用方**没表态**;它与 `'none'`(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。
**成功行与失败行不是同一把尺子。** 开了 `EFFORT_FALLBACK=nearest` 的源上,成功行记的是**映射后的实发档**(读 `response.applied_effort`);失败尝试没有响应、实发档无从得知,记的是**请求档**。故 `GROUP BY reasoning_effort` 不带 `error IS NULL` 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 `nearest` 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 `NULL`
补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE`,两端 DDL 与 `COLUMNS` 同源。**manual 档的下游会看到一处文案变化**:旧表的缺列告警会多点名 `reasoning_effort` 这个维度,并附上对应的 `ALTER TABLE ADD COLUMN` 语句。
### 能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定
`DEFAULT_CAPABILITIES` 共 24 条,每条 `evidence` 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。**读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。** 未能实测的 7 条与原因:
| 模型 | 未覆盖的原因 |
|---|---|
| `claude-opus-5``claude-sonnet-5` | 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;`none` 档还额外依赖网关把 `reasoning_effort=none` 转成 `thinking` 关闭形态,同样未经验证 |
| `gemini-3.1-pro` | 该渠道本型号上游报错(`bad_response_status_code` / `openai_error`),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架**仍未决**,本版不选边 |
| `gpt-5.4` | 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 `gpt-5.5` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 |
| `glm-5``glm-5.1``glm-5.2` | 请求这三个型号时,渠道 5/5 轮把流量路由到 `glm-5.3`(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废 |
`glm-5.2` 的下游风险要单独说:在这条渠道上给它配 `none`,库会照文档推定放行,而真正服务请求的 `glm-5.3` **关不掉推理**;运行期对账会喊,但那是事后。
另有两条与实测相关的收获值得下游知道:同一批实测发现 `zhipu` / `moonshot` 这条渠道**不校验档位值**(未登记的 `medium` 也照单收下并返回 200),故「网关没报错」在这两家上**不构成**「该档受支持」的证据;而 `openai` 那条会校验(清单外的 `max` / `minimal` 被上游 400 拒)。
### 已知限制:`auto` 不等于「强制开推理」(issue #21)
`reasoning_effort=auto`(含它的语法糖 `ENABLE_THINKING=true`)在 `on_base={}` 的三个 provider 段(`openai` / `anthropic` / `google`)上表达的是「**用模型自己的默认档**」,库不注入任何字节。若某模型默认就不推理,这个配置**既不报错也不开推理**。正解是让 `auto` 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。
与之相连有一处**刻意的不一致**,请勿误读:`DEFAULT_CAPABILITIES``MiniMax-M3``supported_efforts` **不含 `auto`**(实测结论——它的默认档不推理),而 `minimax` 段的 wire 会为 `auto` 注入 `{"reasoning_effort":"medium"}` 并被放行。`resolve_thinking` 的 Phase 5 对 `auto` 无条件放行(`auto` 不是写进 `effort_key` 的取值,而是「不写 `effort_key`」),**能力表拦不住这条路**;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。
## 1.3.2(2026-08-28)
**本版不改库代码。** `tools/``tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
`tools/telemetry_retention.py` 此前删哪张表,取决于连接的 `search_path`——它的首项是 `"$user"`,所以**换个角色跑同一条命令,目标可能就换了一张表**。脚本会把解析到的限定名打出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
新增可选参数 `--table <schema>.llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`
| 情形 | 行为 |
|---|---|
| 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 |
| 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE |
| 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) |
| 显式指定的是分区表 | 仍退出 **3** 让路给 `DROP PARTITION`,语义未变 |
退出码契约未新增也未改动。**建议 cron 一律带上 `--table`**:那一行配置从此自己说明删的是哪张表。
### 测试隔离:从"事后观测共享表"改成"权限上做不到"
issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张**三个迁移项目也在写**的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。
行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表**没有任何授权**,`search_path` 万一落空就是 `permission denied` 而不是"但愿有断言发现"。共享表 `llm_calls` 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。
对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。
## 1.3.1(2026-08-26)
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
**版号是 patch,但本版含三处会影响下游的变更**——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。
### 请先读这一条(一): `polygateway.providers` 的深路径 import 断了
推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking``from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`:
| 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 |
|---|---|---|
| `ThinkingCapability``ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` |
| `get_capability``register_capability``resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` |
| `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — |
**前五个请改用包根 import**: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 `ThinkingObservation`,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。`DEFAULT_CAPABILITIES` 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。
`providers.py` 保留的 `ProviderProfile` / `DEFAULT_PROFILES` / `get_provider` / `register_provider` 逐字未动。
拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。
### 请先读这一条(二): `TelemetryRecorder.record_llm_call` 从 24 参变 25 参
新增 keyword-only 参数 `thinking_observation: str`,**且按该 Protocol 的既有纪律不设默认值**(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。**自定义 recorder 实现必须同步补这个参数**,否则调用时 `TypeError`。库自带的 `SQLiteRecorder` / `PostgresRecorder` 已同步,不受影响。
`TelemetryRecorder` 之外的端口逐字未变;`TelemetryStatusProvider` 不受影响。
### 请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来
2026-08-25 实测: M3 非流式开启推理时 `completion_tokens` 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 `reasoning_content` 正文、也没有 `usage.completion_tokens_details`——**钱花了,东西一个字都拿不到**。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 `unknown`,并按 `(模型, 方向)` 发**一次** warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。
要拿到推理正文,该模型请走**流式**路径(实测 185 字符正文完整)。
### 诊断纠正: 不是模型不推理,是 MiniMax 停报 `completion_tokens_details`
issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 `httpx` 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
真正变的是 **MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关、同一 key 上照常返回),`reasoning_tokens` 因此恒为 `None`。而库把「推理是否发生」全押在这一个字段上,于是**手里握着 185 字符推理正文,却对外报告「没推理」**。
缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`
### 三态,以及它为什么不能折叠成布尔
`LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序:
| 值 | 判据 |
|---|---|
| `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 |
| `unknown` | 两个信号双缺,判不出来 |
**`unknown``absent` 不是一回事**,把前者折叠进后者正是本次故障的病根。`unknown` 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 `unknown` 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。
对下游的口径变化: 统计「未推理」**不要再写 `reasoning_tokens IS NULL OR = 0`**,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 `thinking_observation` 分组,`unknown` 独立成一档。
### 声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警
推理能力表(`can_disable`)是静态声明,而静态声明**必然过期**——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。
本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):
| 请求方向 | 观测 | 告警内容 |
|---|---|---|
| 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 |
| 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 |
| 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 |
`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。
### 遥测新增一列 `thinking_observation`
`llm_calls` 加一列 `thinking_observation TEXT`(可空,取值 `observed` / `absent` / `unknown`),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 `INSERT` 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。
照 README「生产部署 DDL 模板」部署的下游**不需要改模板**: 那份模板用 `LIKE llm_calls_seed` 从库自己建出的表派生列,与 `telemetry/schema.py` 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。
### 其他
- 缓存回放的 `thinking_observation``ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)**降级为 `unknown` 并单独告警,响应内容照常复活**——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
- M3 的推理能力 `evidence` 刷新到 2026-08-25 复测。`can_disable` **仍为 `True`**(`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;`enable_thinking``thinking={"type":"enabled"}` 对该模型无效,只有 `reasoning_effort` 是真开关。
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
+16 -3
View File
@@ -1,21 +1,34 @@
.PHONY: install test lint format check ci wiki wiki-check
.PHONY: install test lint format check ci wiki wiki-check shared-table-gate
ENV := PolyGateway
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
# 留着它是因为字面量回归最常见、也最便宜拦。
shared-table-gate:
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
echo ""; \
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
exit 1; \
fi
install:
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
test:
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
lint:
lint: shared-table-gate
conda run -n $(ENV) ruff check src/ tests/ --fix
conda run -n $(ENV) lint-imports
format:
conda run -n $(ENV) ruff format src/ tests/
check:
check: shared-table-gate
conda run -n $(ENV) ruff format --check src/ tests/
conda run -n $(ENV) ruff check src/ tests/
conda run -n $(ENV) lint-imports
+8 -4
View File
@@ -16,9 +16,11 @@
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded``dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
@@ -351,7 +353,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--table <schema>.llm_calls` 把目标钉死**,不给则由连接的 `search_path` 推断) |
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
@@ -361,6 +363,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
## 错误模型(四分类)
@@ -399,7 +403,7 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
| 键形态 | 作用 |
|---|---|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.3.0"
version = "1.3.3"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
@@ -81,6 +81,7 @@ layers = [
"polygateway.config",
"polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
+33 -6
View File
@@ -219,6 +219,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 消灭 `"qwen" in provider``model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability``DEFAULT_CAPABILITIES``get_capability`/`register_capability``resolve_thinking``observe_thinking``reconcile_thinking``ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -370,7 +372,7 @@ flowchart TB
| `cache_hit` | bool | 是否缓存命中 |
| `call_id` | str | UUID,每次**尝试**独立 |
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens``model_reported`(2026-07-31,issue #3,见下)。
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens``model_reported`(2026-07-31,issue #3,见下)`thinking_observation`(2026-08-25,issue #16/#17,见下)、`applied_effort`(2026-09-05,issue #20,见 §7.5/§7.8 与下文推理档位段:本次**实际**跑在哪一档,`nearest` 映射后与请求档分叉,`None` = 调用方不表态或该路径无推理语义)
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
@@ -381,6 +383,22 @@ flowchart TB
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
| 值 | 含义 | 判据(按证据硬度排序) |
|---|---|---|
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
**声明 × 观测对账(同批;判据 2026-09-05 由布尔改档位,issue #20)**: `reconcile_thinking` 把本次**实际发出去的档位**(`effort: Effort | None`,即 `TransportResult.applied_effort`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。判据必须写成 `effort is Effort.NONE` 的**身份比较**才落「要求关闭」一支,其余任何档(含 `auto`)落「要求开启」一支——`Effort.NONE` 的取值是非空串 `"none"`,任何靠真值性的写法(`if not effort`)恒为假,会把每个强度档送进关闭分支、告警方向整个颠倒。`none × unknown``None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, 生效档位)` 集合(第三段 2026-09-05 由 `enable_thinking` 改为**实际发出的档**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音;而档位根本不经过 `enable_thinking` 那个字段,不改就是同一模型的所有档共用一个键),与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
@@ -506,12 +524,14 @@ flowchart TB
### 7.5 响应缓存
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
- `reasoning_effort`(2026-09-05,issue #20): **请求级**档位,仅 `is not None` 时参与(判据不能用真值性——`Effort.NONE` 是「明确要求不推理」,与 `None`「不表态」拿到的是两种响应,合并即毒化)。它不能靠 `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,同一个 client 上跑 low 与 max 在它眼里毫无分别,不进 key 就是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。**源级** `reasoning_effort` 则与 `enable_thinking` 同规则并入 `_fingerprint_mark`(仅表态时追加,故全源不表态时字面量逐字不变、存量缓存不冷启动;两者取值域不相交,`"none"`/`"low"`… vs `true`/`false`,追进同一个列表不会摘要成同一身份)。
- **key 记的是请求档,不是 `nearest` 映射后的生效档**: `CacheMW` 在洋葱里比 transport 更外一层,查缓存时 `resolve_thinking` 尚未执行,生效档根本拿不到。副作用是被映射到同一档的两个请求各占一个缓存槽(存两份相同响应,浪费但不毒化)。**由此的已知边界**: 能力表更新导致映射结果变化时(如某模型新增 `minimal` 档),请求档算出的 key 不变而实际发出的字节变了,会命中按旧映射存下的响应——能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace``cache_salt`
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
@@ -521,7 +541,7 @@ flowchart TB
### 7.7 多源与选源
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`reasoning_effort``effort_fallback`(2026-09-05 issue #20: 前者是本源默认推理档位,`None` = 不表态、`Effort.NONE` = 要求不推理,构造期与 `enable_thinking` 语义矛盾即 `ValueError`;后者取 `error`(缺省)或 `nearest`,决定请求档打空时报错还是映射到最近档)/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
@@ -533,7 +553,7 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。
**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
@@ -541,6 +561,12 @@ flowchart TB
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
**`reasoning_effort` 列(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL``emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档。
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter``_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
@@ -622,7 +648,8 @@ src/polygateway/
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
├── providers.py # D11 provider 注册表
├── providers.py # D11 provider 注册表(只回答 provider 是什么)
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
├── sources.py # SourceConfig + 选源策略
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
├── telemetry/ # sqlite.py / postgres.py / pricing.py
@@ -630,7 +657,7 @@ src/polygateway/
└── streaming.py # 三层活性看门狗(纯函数)
```
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured``providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
---
+1 -1
View File
@@ -28,7 +28,7 @@
|---|---|---|
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(thinking 注入/思考流字段声明) |
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
| 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 |
| 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
@@ -0,0 +1,240 @@
---
type: design
node_id: design:2026-08-25-thinking-observability-design
title: "推理可观测性一等化(issue #16 + #17)"
date: 2026-08-25
---
# 推理可观测性一等化(issue #16 + #17
> 类型:design|日期:2026-08-25|状态:待人类确认
> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。
> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。
## 1. 问题不是 issue 说的那个
issue #16/#17Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。
**库自己握着决定性证据却没用它**`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。
所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。
## 2. 根因三层
| # | 缺陷 | 只修外层会留下什么 |
|---|---|---|
| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 |
| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 |
| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** |
第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。
## 3. 设计主张
一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。**
三条纪律贯穿全文:
- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。
- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。
- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。
## 4. 数据模型
### 4.1 `ThinkingObservation` 三态(新增,响应侧)
```python
class ThinkingObservation(StrEnum):
OBSERVED = "observed" # 确证推理发生
ABSENT = "absent" # 确证未推理(正面证据)
UNKNOWN = "unknown" # 无任何信号,判不出来
```
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7import-linter 契约执法)。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块,契约当场判红。纯值类型归最内层、决策逻辑归上层,是本设计的分层落法。
`StrEnum` 而非裸 `str` 常量:取值域显式、可类型检查,且它是 `str` 子类,`dataclasses.asdict` + `json.dumps` 天然可序列化(缓存回放路径见 §6)。
裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序:
| 条件 | 结果 | 理由 |
|---|---|---|
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 |
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
| 其余(`None` | UNKNOWN | 无信号,不猜 |
**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**transport 收集 `reasoning_content` 时只判 truthy`openai_compat.py`),上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入,校验后使用(P5)。
映射到实测:
| 场景 | observation | 是否诚实 |
|---|---|---|
| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 |
| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) |
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**,见下 |
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |
**`UNKNOWN` 不具证伪力,不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`,这意味着库无法证明推理真的关掉了。对账(§5)能提供的保障只有一个方向:**若模型真的推理了,可观测路径会把结果翻成 `OBSERVED`,告警随之触发**——M3 流式正属此列(关闭档若失效,正文会冒出来)。而不可观测路径(M3 非流式)没有任何保障,这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径,不是全部路径**。
`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。
### 4.2 明确不做:不把"可观测性"写进能力表
诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool``observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。
同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。
## 5. 对账:声明 × 观测
在 transport 拿到结果处做一次比较,矛盾即 warning:
| 请求方向 | 观测 | 能力表 | 处置 |
|---|---|---|---|
| `enable_thinking=False` | OBSERVED | 已登记 `can_disable=True` | **warning**:能力表漂移——声明说可关闭,实测推理了。附 model 与 `evidence` 日期,指路 `register_capability` |
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**:关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `enable_thinking=True` | ABSENT | 任意 | **warning**:注入了开启参数,上游明确上报未推理 |
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**:推理参数已注入但本路径观测不到,无法确认是否生效;**若为非流式路径,推理内容可能已计费却不回传**(M3 实测 completion 53 vs 关闭档 3 |
| `False` | UNKNOWN | 任意 | 不表态——不能证伪(§4.1) |
| `None`(不干预) | 任意 | 任意 | 不表态——调用方没提要求,无从谈"违背" |
前两行必须分开:`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning,那是**事前猜测**;这里的对账是**事后实证**,两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
第四行是 issue #17 关切的"静默失效"的诚实版本:库不再默不作声,而是明说"我注入了,但我看不见结果"。M3 非流式每次都落这一档,故节流不可少。
**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。
**节流**per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。
这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。
## 6. 落点清单
**源码**
| 文件 | 变更 |
|---|---|
| `types.py` | 新增 `ThinkingObservation`(枚举归最内层,§4.1);`LLMResponse``thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
| `providers.py` | 收缩为纯注册表:`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider` |
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"(库外无第三方实现者),故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**,属 CLAUDE.md 强制人类确认档 |
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) |
| `middleware/retry.py` | 透传新字段 |
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段;三个 `emit_*` 各传一行;`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**,不新开调用点(§12 |
| **`middleware/cache.py`** | `_rehydrate``LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) |
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`INSERT 字段 24→25,物理列 25→26 |
| `telemetry/sqlite.py``telemetry/postgres.py` | 实现新参 |
| `client.py` | import 路径改指 `thinking.py` |
| `__init__.py` | 新增包根导出,见 §7 |
**测试**
`tests/unit/``test_types.py`(默认值为 UNKNOWN、位置构造兼容、枚举归属模块)、`test_ports.py`(端口签名冻结测试与 recorder 替身)、`test_openai_compat.py`(裁定四分支、优先级、对账三类告警、节流只喊一次)、`test_retry.py`(透传)、`test_telemetry.py`(列数/列序/组装)、`test_cache.py`(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、`test_package.py`(包根导出面,比照 `TelemetryStatus` 先例)、`test_providers.py`(拆分后的注册表);`tests/integration/test_postgres_telemetry.py`(新列 backfill 与 round-trip);`tests/e2e/test_thinking_live.py`(判据重建,§8)。
**文档**(发布清单第 1 步要求构建前改完)
`README.md` 的"必录 24 字段"→ 25**须用 `inspect.signature` 实测而非凭记忆**`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构(补 `thinking.py`);`research-wiki/schemas/llm-calls.md`(标题仍写"22 字段",已过期两轮,本次一并订正为 25);`research-wiki/index.md`(登记本 design 与 finding;`CHANGELOG.md`(断裂项置顶,§13)。
`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。
默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
## 7. 模块边界:为什么新建 `thinking.py`
现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。
| 模块 | 职责 | 内容 |
|---|---|---|
| `providers.py` | **provider 是什么** | `ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider` |
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError``observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1 |
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
**同时把公共符号提升到包根导出**`ThinkingCapability``ThinkingObservation``register_capability``get_capability``resolve_thinking``ThinkingUnsupportedError``__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。
破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。
## 8. e2e 判据重建
四条红用例的病根是判据盲区,不是被测行为。逐条重建:
| 用例 | 旧判据 | 新判据 |
|---|---|---|
| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 |
| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED****删除 `_ON_MIN_COMPLETION` 魔数退路** |
| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 |
| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400 |
| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 |
| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 |
删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。
**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。
**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。
同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。
## 9. 被否决的路
| 备选 | 否决原因 |
|---|---|
| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 |
| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) |
| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) |
| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 |
| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) |
| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 |
## 10. 非功能维度
**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。
**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。
**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`
**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。
**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。
## 11. 错误处理与测试策略
新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。
| 层 | 覆盖 |
|---|---|
| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;schema 列数与列序断言(既有测试自动抓);包根导出面 |
| 集成 | SQLite/PG 新列 backfill 与 round-trip(既有测试模式) |
| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 |
**先失败后通过的证据**`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。
## 12. 明确不做
**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方(`middleware/telemetry.py` 文件头即如此声明)。三个 `emit_*` 是三个语义不同的入口(逐次尝试 / 缓存命中 / 终态失败),各自组装参数是职责所在,不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。
**不改 M3 的 `can_disable`**2026-08-25 复测 `reasoning_effort=none` → prompt 194= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。
**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。
## 13. 版本号
本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。
曾建议 1.4.0,理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。
**人类 2026-08-25 决定:发 1.3.1。** 决定已记录,实施按此执行。既然版号不再承担预警职责,预警必须由 CHANGELOG 独立扛起:断裂项与改法置于本版条目**最前**,沿用 1.3.0"请先读这一条"的体例,不得只在中段一笔带过。
## 14. 验收标准
- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次
- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED`
- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿
- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`
- 端口 `record_llm_call` 25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿
- 缓存回放的 `thinking_observation``ThinkingObservation` 实例而非裸字符串
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
- README 的遥测字段数经 `inspect.signature` 实测更新为 25ARCHITECTURE §8 模块结构含 `thinking.py``schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
@@ -0,0 +1,234 @@
---
type: design
node_id: design:2026-08-26-issue18-pg-test-isolation
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
date: 2026-08-26
---
# issue #18:隔离靠**权限强制**,目标靠**显式声明**
> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**7 条写真表的用例**全迁**`public.llm_calls` 里那 11 行历史孤儿行**不清理**。
## 1. issue #18 的诊断只对了一半
issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效:
| 失效方向 | 表现 | 标 `slow` 之后 |
|---|---|---|
| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 |
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |
这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。
## 2. 根因三层
| 层 | 事实 | 后果 |
|---|---|---|
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 |
| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 |
| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 |
L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
## 3. 设计主张
1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。
## 4. 变更 A`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
### 4.1 语义:声明即目标,不是"声明后比对"
两种可能的实现要先分清:
| | 做法 | 结果 |
|---|---|---|
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
选定做法的实现落点只有一处——`_purge_postgres``to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。
三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。
### 4.2 参数与校验
| 规则 | 行为 | 理由 |
|---|---|---|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.``\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 |
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
| 无权限 | 后续 `COUNT``PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
### 4.3 目标白名单:为什么表名段不可变
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at``tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。
`--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。
### 4.4 未声明时的提示
`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行:
```
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
```
只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。
## 5. 变更 B:测试角色化——把安全网换成权限边界
### 5.1 模型
**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path``--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。
每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是:
- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
- `tmp``public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权
**必须换角色的原因**`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。
防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT``DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`
**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。
### 5.2 最坏情况从"事后观测"变成"确定性红灯"
| 情形 | 旧 | 新 |
|---|---|---|
| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** |
| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public` |
`_public_count` / `before_public` / 那条 `assert` 整体删除。
### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网
用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`
- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门)
- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆
这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。
## 6. 变更 C7 条用例迁出 `public`
| 用例 | 迁移后验的东西 |
|---|---|
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) |
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) |
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 |
### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个
| 职责 | 落点 | 处置 |
|---|---|---|
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 |
删除行隔离用途时调用点做**机械替换**(`_cid("c1")``"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。
### 6.2 顺带封掉一个仓库自己已记载的隐患
`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template``except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
## 7. 变更 D`conftest.py` 收敛 + lint 门
### 7.1 一个沙箱工厂取代七处样板
`tests/integration/conftest.py` 新增:
| fixture | 职责 |
|---|---|
| `pg_admin_dsn`session | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
| `pg_sandbox`function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |
三条硬约束(缺一条工厂就会自己变成污染源):
1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。
2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。
今天这套样板在两个文件里重复**七处**(`legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema``least_privilege_dsn``least_privilege_pre_tenant_dsn``production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。
### 7.2 机械化执法
`make lint` / `make check` 各加一步:
```
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
```
§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。
**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。
## 8. 明确不做
| 不做 | 理由 |
|---|---|
| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 |
| 建临时数据库(而非 schema | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 |
| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) |
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 |
| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 |
## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决)
| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|---|---|---|
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 |
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 |
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
## 10. 版本号与发布
**1.3.2**patch)。需在 CHANGELOG 里如实写明:`tools/``tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。
发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`
## 11. 验收标准
| # | 判据 | 验证方式 |
|---|---|---|
| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG |
| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 |
| 2 | 未给 `--table``--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 stdout",计划阶段核出该判据不可执行,就地更正) |
| 3 | 最坏情况(search_path 落到 public**删不掉任何行**且退出 2 | §5.3 新用例 |
| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
| 5 | `tests/``public.llm_calls` 零命中 | `make lint` |
| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 |
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
| 6c | `test_schema_has_frozen_columns_in_order``table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles``pgw_%` 残留 |
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
## 12. 审查留痕(Codex2026-08-26
报 6 项实质问题,**全部采纳**,其中两项为阻断级:
| # | 意见 | 处置 |
|---|---|---|
| 1 | **阻断**`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 |
| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" |
| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 |
| 4 | `test_schema_has_frozen_columns_in_order``information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 |
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 |
| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b |
**一处处置与建议不同**Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
@@ -0,0 +1,355 @@
# 推理档位一等化设计(issue #20 及其一般形式)
- **日期**: 2026-09-04
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
- **影响面**: `SourceConfig`/`ChatRequest` 公共类型、`ProviderProfile`/`ThinkingCapability` 公共类型、`resolve_thinking`/`reconcile_thinking` 公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、`.env`
- **人类拍板(2026-09-04)**: 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」
- **人类复核(2026-09-04,针对 Codex 异议)**: `effort_fallback` 的最近档映射**要实现**,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性
---
## 1. 问题不是 issue #20 说的那个
issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models``reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议:
| # | 结论 | 证据 |
|---|---|---|
| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 |
| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 |
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 |
第 1 条意味着 issue #20 建议的 `can_disable=True` 不能登记:我们发出去的 `reasoning_effort:"none"` 是个**未定义值**,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。
第 2 条意味着现有 `minimax` profile 那条「`thinking_on` 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。
**真实缺口**: `enable_thinking: bool | None` 这个类型表达不了现实。补数据不能修复类型。
## 2. 现状审计(旧行为逐条处置)
替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明:
| # | 现有行为 | 处置 |
|---|---|---|
| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) |
| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) |
| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) |
| 4 | `evidence: str` 强制附实测出处 | **保留**,且强化: 初始表全部标注「文档推定,待实测」 |
| 5 | `resolve_thinking` 四道关卡(不表态/形态未知/能力未登记/不可关闭) | **保留四关的顺序与语义**,判据从 bool 换成档位(§4.1) |
| 6 | 能力未登记 → warning 后尽力注入 | **保留**(新模型不该被库挡住,ARCH §5 R4) |
| 7 | `observe_thinking` 多信号裁定三态 | **保留**,不改一行 |
| 8 | `reconcile_thinking` 声明 × 观测对账,矛盾返回文案、不抛错 | **保留**,判据扩展到档位(§4.3) |
| 9 | transport 按 `(source, model, direction)` 节流告警 | **替换**: 节流键的 `direction` 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 |
| 10 | `ThinkingUnsupportedError(ValueError)`,由 transport 翻译为 `RequestRejectedError` | **保留**,新增的档位错误走同一条路 |
| 11 | `_build_payload``resolve_thinking` 结果先于 `extra_body`/`overlay` | **保留**(顺序即优先级,issue #4 决策 A) |
| 12 | 缓存 key 不含任何推理参数 | **修复**(§5,现存缺口) |
| 13 | 遥测无档位列 | **新增**一列(§6) |
**有意放弃**: 无。第 3 条的 `can_disable` 是唯一的破坏性变更,迁移见 §12。
## 3. 数据模型
### 3.1 档位词汇
八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套):
```python
class Effort(StrEnum):
NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定
MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium"
HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
```
`none` 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 `none` 在不在该模型的支持列表里。
`auto` 不可省(自审补): newapi 上 26 个模型里有 9 个是**纯开关型**(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 `auto` 就只能拿某个强度档冒充「开」,而那正是现有 `thinking_on` 硬编码 `medium` 的病根。`auto` 的 wire = `on_base` 不附 `effort_key`,恰好等于旧的 `thinking_on` 行为。四家参考实现都有这一档(cherry 的 canonical selection `'default'|'none'|'auto'|Effort`;new-api 的 `ModeAdaptive`)。
`Effort``types.py`(最内层纯值类型),与 `ThinkingObservation` 同处一处,理由相同: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import。
### 3.2 `ThinkingCapability`: 能力(按 model)
```python
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强
evidence: str
```
**不设 `default_effort` 字段**(Codex 审查采纳): 初稿有此字段,唯一消费者是「`enable_thinking=True` 等价于哪档」;自审把该语法糖改成 `Effort.AUTO` 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 `NULL`(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是**文档知识**,写进 `evidence` 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。
三个派生量,不单独存字段(存了就会漂移):
| 派生 | 定义 | 用途 |
|---|---|---|
| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 |
| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) |
| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) |
OpenRouter 与 LiteLLM 两家**独立收敛到了同一形状**(`supported_efforts`+`default_effort` / `reasoning_effort_levels`+`default_reasoning_effort`),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是**面向展示**的目录(要在 UI 上显示默认档),本库是**执行**路径,默认档不参与任何判定,故不设该字段。
### 3.3 `ProviderProfile`: 形态(按 provider)
```python
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态
on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知
effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念
```
`ProviderProfile.thinking_on/thinking_off``thinking: ThinkingWire` 取代。`None` 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。
四个形态样例(经 new-api 中转的口径):
| provider | off | on_base | effort_key |
|---|---|---|---|
| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` |
| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) |
| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
| minimax | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
### 3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect
cherry 有两层我们**明确不做**:
1. **endpoint-keyed 的 per-model wire 覆盖**。它需要这层,是因为同一模型在 `openai-chat` / `openai-responses` / `anthropic-messages` / `google-generate-content` 四种协议下形态不同。**本库只有一个 chat transport(`openai_compat.py`)**,所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见 `relaykit/relayconvert/reasoning/intent.go`)。一个协议 = 一层形态。
2. **`wireDialect` 代际方言**(Claude 4.6+ `adaptive` vs ≤4.5 `budget_tokens`;Gemini 3 `thinkingLevel` vs 2.x `thinkingBudget`)。这是**原生协议**才有的问题;我们发 OpenAI 形态的 `reasoning_effort`,代际差异由网关吸收。
同理,`glm-5.2`(有 `none` 档)与 `glm-5.3`(无 `none` 档)**共用同一份 wire**——差别落在 capability 的 `supported_efforts` 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。
## 4. 解析
### 4.1 `resolve_thinking`: 五道关卡
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 |
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** |
| 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** |
| 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 |
**4 必须先于 5**(自审补): `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。
Phase 4 的文案是本设计的一个交付物,而非装饰:
> 模型 'glm-5.3' 无法关闭推理(官方 `thinking.type` 只接受 enabled);最省的档是 'low',请配 `LLM__ZHIPU__1__REASONING_EFFORT=low` 或调用时传 `reasoning_effort=Effort.LOW`。evidence: ...
理由: 该分支若只报错不给出路,下游会去找 `extra_body` 那条绕过的路——**那正是 issue #20 的成因**。报错必须带可执行替代,否则等于把用户推回起点。
映射(Phase 5 的逃生口)默认关闭,由 `SourceConfig.effort_fallback="nearest"` 显式开启,按 `supported_efforts` 的顺序取最近档并 warning。
> **Codex 审查异议与人类复核**: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后**确认实现**——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项**随本期一并实现**,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的 `medium→max` 在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。
### 4.2 生效档位的优先级
```
request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None
```
`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
| 旧写法 | 等价于 |
|---|---|
| `enable_thinking=False` | `reasoning_effort=Effort.NONE` |
| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 |
**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|---|---|---|---|
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** |
| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** |
| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** |
第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。
**第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。
**同源同时配 `enable_thinking` 与 `reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。
### 4.3 `reconcile_thinking`: 对账扩展
现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾:
- 请求 `none`、模型登记 `can_disable=True`、却观测到 `OBSERVED` → 既有文案,**保留**(这正是 issue #20 第 3 条要恢复的机制)。
- 请求非 `none` 档、观测到 `ABSENT` → 既有文案,保留。
- **不做**「档位高低与 `reasoning_tokens` 多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。
### 4.4 归一化不变式(实现期补,2026-09-05)
库内一切档位判定都是 `is Effort.X` 的身份比较,故**每条能让档位进入库内的入口都必须先归一**
(`types.coerce_effort`)。裸字符串不归一的后果不是报错而是**静默判否**——`("none" is Effort.NONE)`
恒假,于是「已关闭」被当成「没表态」。
已知三条入口,缺一即漏:
| # | 入口 | 归一点 |
|---|---|---|
| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` |
| 2 | 构造函数全量注入 `SourceConfig(...)``chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 |
| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` |
| 4 | **公共函数 `resolve_thinking()` 直调** | 函数入口自行 `coerce_effort`(2026-09-05 独立验证查出) |
第 3 条是 T8 加 `applied_effort` 字段时才浮现的: 响应进 Redis 走 JSON,`StrEnum` 存成裸串,
命中回放时类型已丢。与 `thinking_observation` 当年的坑**逐字相同**(见 issue #16/#17),故按同一
先例处置: 域外取值降级为 `None` 且**不作废整条缓存**——多项目共用 Redis 时互相打缓存是老问题,
为一个可观测字段丢掉整条响应不划算。
第 4 条是本次换代**自己造出来的**: 该函数在 `__all__` 里,第三参数由 `bool` 换成 `Effort` 后,下游最自然的写法就是从 JSON/配置读出来的裸串 `"low"`。不归一则 `_inject``.value``AttributeError`——一个未文档化、不属四分类的异常。
新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 `coerce_effort`
## 5. 缓存 key
**更正一个误判(Codex 审查指出)**: 源级 `extra_body``enable_thinking` **早已进 key**——经 `build_model_fingerprint``_fingerprint_mark`(`client.py`),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计**不存在**先前稿本断言的「现存毒化缺口」,那是把 `CacheMW` 只读 `request.sampling` 误当成了全部 key 来源。
真正需要处置的是两处,均因请求级档位而新增:
| 层 | 处置 | 理由 |
|---|---|---|
| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
**已知取舍原样延续**: ARCH §7.5 已记载 `model_fingerprint` 是**集合级**而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 `reasoning_effort` 后该取舍不变,本设计不扩大战线去改它。
**冷启动代价**: 只有新配 `REASONING_EFFORT` 的源冷启动一次;存量只配 `ENABLE_THINKING` 的源字面量逐字不变。
**key 用请求档,不用 `nearest` 映射后的生效档**(T6 实现时定,理由在此补正): 决定性的原因是
`CacheMW` 位于洋葱中比 transport 更外的一层,查缓存时 `resolve_thinking` 尚未执行,生效档
**根本拿不到**。副作用是被映射到同一档的两个请求(`minimal``low` 都映射到 `low`)各占一个
缓存槽,存两份相同响应——浪费但不毒化,可接受。
**由此带来一条已知边界**(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果
变化时(如某模型新增 `minimal` 档),请求档 `minimal` 算出的 key 不变而实际发出的字节变了,
会命中按旧映射存下的响应。能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表
与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace``cache_salt`
## 6. 遥测
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 `telemetry/schema.py` 模块 docstring)。
记的是**本次调用生效的档位**,不是配置值——`None`(不表态)与 `'low'` 必须能区分,故可空。
不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE` 机制,两端 DDL 与 `COLUMNS` 同源(schema.py 是单一事实源)。
## 7. 备选方案对比
| | 方案 | 改动面 | 权衡 |
|---|---|---|---|
| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** |
| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py``Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap``budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** |
**推荐 B**。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,`ThinkingWire` 增一个 `budget_key` 字段即可增量抵达,不必现在就上解释器。
## 8. 初始能力表(全部标注「文档推定,待实测」)
来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。**这是待验证的假设,不是结论**——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下是三档、在 `perplexity/` 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。
**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置:
| 情形 | 处置 |
|---|---|
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 |
| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 |
第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash``claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|---|---|---|---|
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
| glm-5.2 | none, high, max | max | ✓ |
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) |
| kimi-for-coding | 未查到 | — | ? |
| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ |
| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ |
| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+`none` 经网关转 `thinking` 关闭) | high | ✓ |
| claude-haiku-5 | 推定同上 | — | ? |
| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(**打架**) | ✗ |
| gemini-3-flash | low, medium, high | — | ? |
| MiniMax-M3 | none, auto | auto | ✓ |
| MiniMax-M2.5, M2.7 | auto(**仅此一档**) | auto | ✗ |
| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ |
| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ |
三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。
## 9. 非功能维度
| 维度 | 回答 |
|---|---|
| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 |
| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
| **幂等** | 纯函数,无副作用,同输入恒同输出。重复调用安全 |
| **持久化** | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 `PGW_TELEMETRY_SCHEMA_MODE`,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) |
## 10. 错误处理与测试策略
**错误分类**: 全部落 `RequestRejectedError`(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——`thinking.py``ThinkingUnsupportedError(ValueError)`,transport 在请求期翻译。
装配期 vs 运行期: 源级配置(`SourceConfig.reasoning_effort`)在**构造期**校验并报错;请求级(`ChatRequest.reasoning_effort`)只能在**运行期**校验,落 `RequestRejectedError` 上抛。
**测试策略**(先失败后通过,每条对应一个行为):
| 层 | 用例 |
|---|---|
| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 |
| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
| integration | 遥测 `reasoning_effort` 列在两端(sqlite/pg)落值正确,不表态时为 NULL |
| e2e(`slow`) | 经 new-api 对 §8 表逐模型实测,校正 `supported_efforts`;标 `slow`(成败取决于外部服务当下状态) |
## 11. 明确不做
1. **档位与 `reasoning_tokens` 的运行期对账**(§4.3): 无可判定的函数关系,拿它报警是噪声。
2. **token 预算型控制**(`thinking_budget`/`budget_tokens`): qwen 系支持,但当前无下游需求;`ThinkingWire` 可增量加 `budget_key` 抵达。
3. **原生协议 wire 与代际方言**(§3.4): 本库只有一个 OpenAI 兼容 transport。
4. **档位对采样参数的联动**: DeepSeek 思考模式不支持 `temperature`/`top_p`,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。**本设计不代下游做参数裁剪**——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。
5. **压测本身**: 「不同档位是不是真有用」是 `harness-eval` 范畴,依赖本设计的遥测列,不属于本设计。
## 12. 迁移与兼容
**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`):
| # | 位置 | 变更 | 谁会断 |
|---|---|---|---|
| 1 | `ThinkingCapability` | 构造签名 `can_disable``supported_efforts` | 自建能力表的调用方 |
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 |
| 4 | `thinking.resolve_thinking()` | 第三参数换语义(`bool``Effort`),**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 读侧代码一律断 |
| 5 | `providers.ProviderProfile` | `thinking_on`/`thinking_off``thinking: ThinkingWire` | 自建 profile 的调用方 |
五者都在包根导出面或端口面上。
**先更正**(Codex 审查指出): 初稿称「已核实 `reference/` 三项目无调用点,实际影响面为零」——**该结论不成立**。`reference/` 下当前**没有** GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 `grep` 因目录不存在而输出空,被误读成「无匹配」。
真实的库内调用点(可复验):
| 位置 | 用法 | 处置 |
|---|---|---|
| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 |
| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 |
| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 |
| `__init__.py` | 包根导出 `ThinkingCapability`/`register_capability` | 符号名不变,构造形态变 |
**兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。
**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。
**非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env``ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。
## 13. 验收标准
1. 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。
2. 同 messages 不同档位不再互相命中缓存。
3. 遥测能按档位分组(压测的前置条件)。
4. `.env` 只配 `ENABLE_THINKING` 的存量下游行为不变(回归测试)。
5.`DEFAULT_CAPABILITIES` 的条目**仅限** §8 中无 `?`/无冲突者,每条 `evidence` 标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。
6. import-linter 契约不破(`Effort``types.py`,不产生反向依赖)。
+16
View File
@@ -0,0 +1,16 @@
---
type: design
node_id: design:reasoning-effort
title: "推理档位一等化设计(issue #20 及其一般形式)"
date: 2026-09-05
---
# 推理档位一等化设计(issue #20 及其一般形式)
正文: `2026-09-04-reasoning-effort-design.md`。状态: **2026-09-04 人类已批准**
- **选定方案**: 方案 B「能力表档位化 + 源级/请求级双入口」。`Effort` 八档封闭枚举(含 `auto`)入 `types.py`;`ThinkingCapability``can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`(「能不能关」= `none` 在不在列表里);`ProviderProfile` 的两个固定片段改为 `ThinkingWire(off / on_base / effort_key)`;生效档位 = 请求级 > 源级 > `enable_thinking` 语法糖。
- **触发与真实缺口**: issue #20 字面要一条 zhipu profile,但补它不能解决它自己描述的失败——GLM-5.3 官方强制推理(智谱文档、cherry、OpenRouter 三源一致),`none` 是我们发出去的**未定义值**;而 `medium`(现 minimax profile 硬编码的档)在 GLM/kimi/deepseek 上根本不存在。缺口是**类型**表达不了现实,不是表里少一行。
- **人类三项拍板(2026-09-04)**: 作用域「源级默认 + 请求级覆盖」;档位打空时「默认报错 + 可显式开 nearest 映射」;不可关闭时「报错并给出该模型最低档作为可执行替代」。复核 Codex 异议后追加确认: `effort_fallback` 随本期实现,不因当前无消费者而推迟。
- **被否决备选及理由**: **方案 A 最小补丁**(只补 zhipu profile、维持 bool)——`thinking_on` 填什么档都是错的,`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」间二选一,治标;**方案 C 照抄 cherry 完整 wire DSL**(closed operations、`effortMap``budgetWire`、endpoint-keyed contract)——它需要那层是因为要支持四种端点协议,而本库只有一个 OpenAI 兼容 transport,跨协议转换由 new-api 服务端完成,该复杂度当前无消费者(P1 YAGNI);**`default_effort` 字段**——自审把 `enable_thinking=True` 的语法糖改成 `Effort.AUTO` 后失去唯一消费方,厂商默认档降为 `evidence` 文本;**档位与 `reasoning_tokens` 的运行期对账**——无可判定的函数关系(实测同档 rt 在 8~56 间跳),报警必成噪声;**代下游裁剪采样参数**(DeepSeek 思考模式不支持 `temperature`)——那是「默认值掩盖错误」,记入 evidence 而不写进逻辑。
- **审查留痕**: Claude 自审揪出两处实质缺陷(词汇缺 `auto`,导致 9 个纯开关型模型无档可填、等于把要修的 bug 重新实现一遍;五关顺序错置,使「关不掉」的特殊文案被通用分支吞掉)。Codex 独立审推翻两条**错误断言**: ① 源级 `extra_body`/`enable_thinking` **早已**经 `build_model_fingerprint` 进缓存 key(ARCH §7.5 有明文),不存在先前稿本断言的「现存毒化缺口」;② 「三项目无调用点」是对**不存在的目录**做 grep 得到的空结果,`reference/` 下当前并无三项目,迁移安全性只能是推断。另采纳其三条: 删 `default_effort`、补能力表落库规则、列出库内真实会断的调用点(`tests/unit/test_thinking.py:128` 的位置参数构造)。
+8 -1
View File
@@ -2,7 +2,12 @@
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
## 1. 结构:Diátaxis 四区(2026-07-23 建站,17 页)
> [!CRITICAL]
> **现状(2026-08-02 起):文档站已全量下线,当前只剩 `Home` 一页占位。** 八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛——过期文档比没有文档更危险,它看起来权威。
> Home 页现在做的唯一一件事是**把下游指向真实事实源**:签名/字段/参数语义 → 源码 docstring;全量环境变量键 → `.env.example`;版本变更与下游注意事项 → `CHANGELOG.md`;架构决策与行为论证 → `research-wiki/ARCHITECTURE.md`;快速上手 → `README.md`。
> 历史内容未丢失,全在 wiki 仓库的 git 历史里(`git checkout e78bfb9 -- .`)。**本文以下各节描述的是重建时的目标结构与纪律,不是当前站点的现状**;在文档站重建之前,下面凡指向具体 wiki 页面的条目一律**不可执行**。
## 1. 结构:Diátaxis 四区(重建目标;2026-07-23 建站 17 页,2026-08-02 全量下线)
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|---|---|---|---|
@@ -25,6 +30,8 @@
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md``README.md``.env.example``research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。
## 3. 写作纪律
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
@@ -0,0 +1,94 @@
---
type: finding
node_id: finding:2026-08-25-thinking-observability-regression
title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号"
date: 2026-08-25
---
# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号**
> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online`
> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。
## 1. 为什么要重测
issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。
## 2. 方法
两层探针,都不走 slow 套件:
其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage``message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。
其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse``reasoning_tokens``thinking` 两个字段。两层对照才能定位缺口落在哪一层。
对照组取 `qwen3.7-plus``deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。
## 3. 原始观测
### 3.1 MiniMax-M3,裸 httpx,非流式
| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` |
|---|---|---|---|---|
| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 |
| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 |
| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 |
| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 |
| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 |
| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 |
| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 |
### 3.2 MiniMax-M3,裸 httpx,流式
| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage |
|---|---|---|---|
| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd |
| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23`
### 3.3 对照组(流式)
| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` |
|---|---|---|---|
| deepseek-v4-pro | 不注入 | 135 字符 | **88** |
| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** |
| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 |
| qwen3.7-plus | 不注入 | 350 字符 | **158** |
| qwen3.7-plus | `effort=medium` | 606 字符 | **229** |
| qwen3.7-plus | `effort=none` | 0 | 容器缺失 |
| qwen3.7-plus | 非法值 | — | **HTTP 400** |
### 3.4 用库跑(`LLMResponse` 字段)
| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion |
|---|---|---|---|
| M3 开启,流式 | None | **185** | 69 |
| M3 开启,非流式 | None | **0** | 53 |
| M3 关闭,流式/非流式 | None | 0 | 3 |
| M3 不干预 | None | 0 | 3 |
| qwen 开启,流式 | **205** | 484 | 213 |
| qwen 关闭,流式 | None | 0 | 5 |
## 4. 五条结论
**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。
**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。
**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens``completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。
**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role``reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。
**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。
## 5. 顺带纠正的两处既有认识
**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。
**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。
## 6. `can_disable` 复测
M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。
@@ -0,0 +1,91 @@
---
type: finding
node_id: finding:2026-08-26-issue18-shared-pg-test-isolation
title: "issue #18 实测: 偶发红的是安全网本身,不是被测脚本"
date: 2026-08-26
---
# issue #18 实测:偶发红的是**安全网本身**,不是被测脚本
> 类型:finding|日期:2026-08-26|实例 `polygateway` 库(PostgreSQL 16.14,共享)
> 本文是 `designs/2026-08-26-issue18-pg-test-isolation-design.md` 的事实基础。
> 实测与推断在 §5 明确分界——推断部分未做复现实验,不当作既定事实使用。
## 1. 失败断言的唯一归属
`assert 12 == 61` 只能对应 `test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 的最后一行:
| 断言 | 形态 |
|---|---|
| `_call_ids(schema_dsn) == ["fresh-1", "fresh-2"]` | 列表比较,失败会打印列表 |
| `"将删除行数: 5" in result.stdout` 等五条 | 子串判定,失败不打印数字对 |
| `await _public_count(dsn) == before_public` | **整型比较,唯一能报出 `12 == 61`** |
`before_public` 在 seed 之前取,`12` 是脚本跑完后的复测值。
## 2. 被测脚本没有越界
失败发生在最后一条,意味着它前面全部通过:`_call_ids(schema_dsn)` 恰为 `["fresh-1","fresh-2"]`(临时 schema 里 5 行过期行被删、2 行新鲜行留下)、stdout 里出现 `<临时schema>.llm_calls``将删除行数: 5`、三条批次行齐全。
`search_path` 曾失效、脚本打到了 `public.llm_calls`,那么临时表 7 行一行不少,第二条断言就会先红。**故本次失败与 `telemetry_retention.py` 的行为无关**。
## 3. 共享表的实测现状
`.env``PGW_TELEMETRY_PG_DSN` 直连查得(2026-08-26):
| 项 | 实测值 |
|---|---|
| `public.llm_calls` 行数 | **11**,非分区普通表 |
| 这 11 行的 `created_at` | 全部落在 `2026-07-22 14:00 ~ 14:26` |
| 这 11 行的 `call_id` 形态 | 裸 hex 前缀(`3c915c04``c8071b6a` …)与一个 `c1`**不是** `pgwtest-` 前缀 |
| 表属主 / ACL | `app` / `{app=arwdDxt/app, chs3_test=ar/app}`(无 PUBLIC 授权) |
| `.env` 里那个角色 | `app``rolsuper = true``rolcreatedb = true``rolcreaterole = true` |
| 服务端版本 / 连接 | PostgreSQL 16.14`max_connections = 100`,查时 54 个连接在用 |
| 残留临时 schema / 角色 | 无(`pgw%` 命名下均为空) |
失败时的 `12` 与这个 `11` 行基线同量级;`61` 意味着取快照那一刻库里另有约 49 行,随后消失。那 11 行是一个多月前留下的**孤儿行**:它们早于 7 天截止线,任何一次带 `--apply` 的存量清理都会删掉它们——这本身说明真实共享表上确实存在"测试/工具写完没清干净"的历史。
## 4. 本仓库自己就是共享表的写入方
`tests/integration/test_postgres_telemetry.py` 存在两套并行的隔离手法:
| 手法 | 用在哪 | 是否触碰 `public.llm_calls` |
|---|---|---|
| 临时 schema`legacy_schema``fresh_schema``pre_tenant_schema``partitioned_schema``least_privilege_dsn``least_privilege_pre_tenant_dsn``production_template`) | 需要特定表形态的用例 | 否,teardown 走 `DROP SCHEMA CASCADE` |
| `_RUN_PREFIX` 前缀(模块级 `pgwtest-<uuid8>` | `TestObservabilityColumns::test_values_round_trip``TestSchema` 三条、`TestDegradation` 两条、`TestPoolFootprint` 一条,**共 7 条** | **是**,写入真表,`dsn` fixture teardown 执行 `DELETE ... WHERE call_id LIKE '<前缀>-%'` |
前缀隔离对**读**是完备的(每个进程只看自己的行),对**全表口径的观测**不设防——而 `_public_count` 正是全套件里唯一一处全表口径。
## 5. 实测与推断的分界
**实测(本会话工具输出)**:§1 的断言归属、§2 的失败顺序推理、§3 的全部数字、§4 的用例清单。
**推断(未做复现实验)**:那 49 行的来源。同一 pytest 进程内 `test_postgres_telemetry.py` 排在 `test_retention_tool_pg.py` 之前(文件名序),且其 `dsn` fixture 是函数级、每条用例后立即清理,故同进程解释不成立;最合理的解释是**另一个进程**在同一秒窗口内完成了一轮"写 7 条 → teardown 删掉"的循环——并行的另一个开发会话,或 `~/Projects/m4-worktrees/` 下迁移项目的批跑(三个迁移项目正是用本库往这张表写遥测)。
这条推断不影响结论:无论那 49 行由谁写删,`public.llm_calls` 的行数都是**不归本测试控制的全局可变量**,把它当断言基线在设计上就不成立。
## 6. 与 `_public_count` 的设计意图的落差
该断言的注释写明它要防的是"`search_path` 没生效导致静默删库"。行数快照防不住这件事:
- **假红**:任何外部写/删都让它红(本次即是),而脚本完全正常
- **假阴**:外部并发的增减可以与脚本的误删互相抵消,行数相等则静默放行——它守的是删库,这一半失效才是真正的代价
一个安全属性被编码成对全局可变量的观测,两个方向都不成立。
## 7. 方案可行性的实测(2026-08-26,同一实例)
用一次性角色/schema 做的证伪实验(建 `pgwprobe_r_*` 角色 + `pgwprobe_s_*` schema,跑完全部 `DROP`,实例上无残留):
| # | 探针 | 结果 |
|---|---|---|
| 1 | 角色以自己身份建表 | 属主为该角色(与"用维护角色跑"的现场一致) |
| 2 | `to_regclass('"<schema>"."llm_calls"')` | 正常解析到该表 |
| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** |
| 4 | `to_regclass('"<SCHEMA 大写>"."llm_calls"')` | **返回 NULL** —— 引号限定名区分大小写 |
| 5 | 临时角色**裸连**(不挂 search_path) | `SHOW search_path` = `"$user", public`,`to_regclass('llm_calls')` 命中真表 |
| 6 | 裸连对真表 `SELECT COUNT(*)` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
| 7 | 裸连对真表 `DELETE ... WHERE created_at < now()` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
| 8 | 角色名与 schema **同名**时裸连 | `"$user"` 命中自有 schema,**遮蔽 public** |
第 6、7 条是新方案的核心防线:最坏情况下脚本连数都数不出来,更谈不上删。第 8 条是一条必须写进设计的约束——今天 `least_privilege_dsn` 的角色与 schema 恰好同名,若沿用该形态,"search_path 落到 public"的最坏情况用例会走到自有 schema 上,测出来的是个假现场。
+46 -1
View File
@@ -8,7 +8,7 @@
},
{
"id": "schema:llm-calls",
"label": "表结构: llm_calls(遥测 18 字段)",
"label": "表结构: llm_calls(遥测 25 字段)",
"type": "schema"
},
{
@@ -200,6 +200,16 @@
"id": "plan:plan-issue15-telemetry-pool-lifecycle",
"label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)",
"type": "plan"
},
{
"id": "design:reasoning-effort",
"label": "推理档位一等化设计(issue #20 及其一般形式)",
"type": "design"
},
{
"id": "plan:reasoning-effort",
"label": "实现计划: 推理档位一等化",
"type": "plan"
}
],
"links": [
@@ -384,6 +394,41 @@
"relation": "implements",
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
"added": "2026-08-24T12:05:46.300738+00:00"
},
{
"source": "plan:2026-08-25-thinking-observability-plan",
"target": "design:2026-08-25-thinking-observability-design",
"relation": "implements",
"evidence": "本计划 Task 1-10 实现该设计的全部落点与 §14 验收标准",
"added": "2026-08-26T04:49:16.312785+00:00"
},
{
"source": "finding:2026-08-25-thinking-observability-regression",
"target": "design:2026-08-25-thinking-observability-design",
"relation": "supports",
"evidence": "裸 httpx 与库两层实测(M3 推理正常、MiniMax 停报 completion_tokens_details)是该设计三层根因与三态裁定的事实基础",
"added": "2026-08-26T04:49:17.481308+00:00"
},
{
"source": "finding:2026-08-25-thinking-observability-regression",
"target": "design:2026-08-02-thinking-capability-design",
"relation": "refines",
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
"added": "2026-08-26T04:49:18.648857+00:00"
},
{
"source": "plan:2026-08-26-issue18-pg-test-isolation",
"target": "design:2026-08-26-issue18-pg-test-isolation",
"relation": "implements",
"evidence": "9 个任务逐条实现设计 §4-§11",
"added": "2026-08-26T11:28:33.469681+00:00"
},
{
"source": "plan:reasoning-effort",
"target": "design:reasoning-effort",
"relation": "implements",
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
"added": "2026-09-05T04:07:17.723586+00:00"
}
]
}
+15 -5
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-08-24 15:51 UTC
> 自动生成,更新时间:2026-09-05 04:07 UTC
## design (37)
## design (41)
- [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`
@@ -21,6 +21,7 @@
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-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`
@@ -28,6 +29,7 @@
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
@@ -36,17 +38,21 @@
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (12)
## finding (14)
- [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`
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
@@ -55,7 +61,7 @@
- [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 (32)
## plan (36)
- [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`
@@ -71,7 +77,9 @@
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
@@ -83,8 +91,10 @@
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
- [实现计划: 推理档位一等化](plans/reasoning-effort.md) `plan:reasoning-effort`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
@@ -93,7 +103,7 @@
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1)
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+10
View File
@@ -140,3 +140,13 @@
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正
- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面
- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面
- [2026-08-26 04:49 UTC] 新增边: plan:2026-08-25-thinking-observability-plan --implements--> design:2026-08-25-thinking-observability-design
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
- [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation
- [2026-08-26 11:28 UTC] 重建索引: 91 篇页面
- [2026-09-05 04:06 UTC] 新增 design: 推理档位一等化设计(issue #20 及其一般形式) (design:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
@@ -0,0 +1,529 @@
---
type: plan
node_id: plan:2026-08-25-thinking-observability-plan
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
date: 2026-08-25
---
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
## 目标
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
## 方案概述
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
## 文件结构
**新建**
| 文件 | 职责 |
|---|---|
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
**修改**
| 文件 | 变更 |
|---|---|
| `src/polygateway/types.py` | 新增 `ThinkingObservation``LLMResponse` / `TransportResult` 各增一字段 |
| `src/polygateway/providers.py` | 收缩为纯注册表 |
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
| `src/polygateway/middleware/retry.py` | 透传 |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
| `src/polygateway/telemetry/sqlite.py``postgres.py` | 实现新参 |
| `src/polygateway/client.py` | import 路径 |
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
| 测试 9 个、文档 5 个 | 见各任务 |
---
## Task 1`ThinkingObservation` 与裁定纯函数
**文件**:创建 `src/polygateway/thinking.py``tests/unit/test_thinking.py`;修改 `src/polygateway/types.py``pyproject.toml`
### 行为
`types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
```python
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
```
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
```python
def observe_thinking(
*, thinking: str, reasoning_tokens: int | None
) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按证据硬度排序。
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
if reasoning_tokens is None:
return ThinkingObservation.UNKNOWN
return (
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
)
```
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
```toml
layers = [
"polygateway.client",
"polygateway.config",
"polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
```
层序理由:`thinking.py` 要 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED`reasoning_tokens=0` → ABSENT`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
conda run -n PolyGateway lint-imports
```
预期:新测试全 PASS`lint-imports` 全部契约 KEPT。
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
---
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/providers.py``src/polygateway/client.py``src/polygateway/transports/openai_compat.py``src/polygateway/__init__.py``tests/unit/test_providers.py``tests/unit/test_package.py`
### 行为
`providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError``ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking``_warn_unregistered`
`providers.py` 保留:`ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`
更新 import`client.py``from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py``client.py``TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability``ThinkingObservation``ThinkingUnsupportedError``get_capability``register_capability``resolve_thinking`
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`
### 测试要求(先失败后通过)
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit -q
conda run -n PolyGateway lint-imports
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
```
预期:全 PASS;契约 KEPTimport 成功。
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
---
## Task 3:字段落到响应类型并贯通调用链
**文件**:修改 `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`
### 行为
`TransportResult``LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
```python
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
```
`LLMResponse` 侧补 docstring`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
`middleware/retry.py``_build_response`372-393 行)透传 `thinking_observation=result.thinking_observation`
### 测试要求(先失败后通过)
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN``LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN``usage.completion_tokens_details.reasoning_tokens=0``ABSENT`。流式与非流式各一组。
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
以上在字段落地前全部红(属性不存在)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
```
预期:全 PASS。
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
---
## Task 4:对账告警(声明 × 观测)
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py``tests/unit/test_openai_compat.py`
### 行为
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
```python
def reconcile_thinking(
*,
enable_thinking: bool | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
"""
```
判定矩阵(设计 §5):
| `enable_thinking` | observation | capability | 返回 |
|---|---|---|---|
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence``register_capability` 指路 |
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None``logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py``_warnings()`(`logger.add` 收集)。
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
```
预期:全 PASS。
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
---
## Task 5:缓存回放复活枚举
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
### 行为
`_rehydrate``LLMResponse(**fields)`JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
```python
if "thinking_observation" in fields:
fields["thinking_observation"] = ThinkingObservation(
fields["thinking_observation"]
)
```
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
`_serialize` 无需改动:`StrEnum``str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
### 测试要求(先失败后通过)
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation``"bogus"` 的缓存值,断言按未命中回源。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
```
预期:全 PASS。
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
---
## Task 6:遥测新增一列(端口 → schema → recorder → emitter
**文件**:修改 `src/polygateway/ports.py``src/polygateway/telemetry/schema.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`
### 行为
**端口**`TelemetryRecorder.record_llm_call``thinking_observation: str`**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
**schema**`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT``SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")``COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns``fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
**emitter**`_AttemptUsage``thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure``ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
**recorder 收到的必须是裸 `str`,不是枚举实例**`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
### 数字断言逐处更新(漏一处即红)
| 位置 | 现值 → 新值 |
|---|---|
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24``25` |
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
| `tests/unit/test_telemetry.py:642` | `== 25``== 26` |
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列``26 个` |
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586` 的 `_EXPECTED_COLUMNS[:-2]` → `[:-3]`、`TestBackendColumnParity` 的 `COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608` 的 `_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
### 测试要求(先失败后通过)
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
conda run -n PolyGateway python -c "
import inspect
from polygateway.ports import TelemetryRecorder
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
print('recorder 参数数(不含 self):', len(p) - 1)"
```
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
---
## Task 7e2e 判据重建
**文件**:修改 `tests/e2e/test_thinking_live.py`
### 行为
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation``"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
判据函数改写:
```python
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 观测到推理即为真。
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
"""
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只要没观测到推理即算满足。
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
"""
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
```
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
### 测试要求(先失败后通过)
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
### 验证
```bash
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
```
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
---
## Task 8:能力表 evidence 刷新
**文件**:修改 `src/polygateway/thinking.py`
### 行为
`DEFAULT_CAPABILITIES``MiniMax-M3``can_disable` **保持 `True`**2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
`minimax` profile 上方的注入形态注释同步补记复测日期。
### 测试要求
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
```
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
---
## Task 9:文档同步(构建前必须改完)
**文件**:修改 `README.md``research-wiki/ARCHITECTURE.md``research-wiki/schemas/llm-calls.md``research-wiki/index.md``CHANGELOG.md`
### 行为
**`README.md:21`**`必录 24 字段``25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
**`README.md``<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py``TestProductionTemplate`**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
### Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
### 验证
```bash
grep -n '25 字段' README.md
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
```
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
---
## Task 10:合并前独立验证与发布 1.3.1
**文件**:修改 `pyproject.toml``src/polygateway/__init__.py`
### 行为
版本号两处改 `1.3.1``pyproject.toml``__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`
本任务分两段,**中间是一道人类确认门**。
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1``verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci``pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
**第二段:外发且难以撤销的动作,一律等确认**——合并 main`--no-ff`+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
### 验证
```bash
conda run -n PolyGateway make ci
conda run -n PolyGateway pytest -m slow
python -c "import tomllib,pathlib,re
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
```
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
- [ ] Task 10 提交:`chore: cut 1.3.1`
---
## 任务依赖
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
## 全局纪律
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
@@ -0,0 +1,297 @@
---
type: plan
node_id: plan:2026-08-26-issue18-pg-test-isolation
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
date: 2026-08-26
---
# issue #18 实现计划
> 类型:plan|日期:2026-08-26|分支 `fix/issue-18-pg-test-isolation`
> 实现设计 `designs/2026-08-26-issue18-pg-test-isolation-design.md`(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。
> **本计划不涉及参考实现迁移,保真校验不适用。**
> [!CAUTION]
> **执行期唯一的不可逆风险,写在最前面。** 设计 §5.3 的"最坏情况"用例故意让脚本以裸 `search_path` 跑到共享表上。它**只有在沙箱角色就位之后才可以跑**——若在角色化之前用 `.env``app`(实测 superuser)跑它,`--older-than-days 7 --apply` 会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。
> 这条风险决定了下面的任务顺序:**沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例**。它没有常规意义上的"先红"路径,见 Task 2 的说明。
## 目标
`tests/integration` 不再依赖也不再污染共享表 `llm_calls`,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
## 方案概述
先建 `tests/integration/conftest.py` 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 `telemetry_retention.py``--table SCHEMA.llm_calls`(目标由参数精确解析、绕开 `search_path`,表名段锁死);随后把 `test_postgres_telemetry.py` 的 7 条用例迁出真表、拆分 `_RUN_PREFIX` 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
## 涉及技术
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 `search_path` 语义 / argparse。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `tests/integration/conftest.py` | **新建** | `PgSandbox``pg_sandbox` 工厂;admin DSN 私有化 |
| `tests/integration/test_pg_sandbox.py` | **新建** | 工厂自身的行为测试(含 setup 中途失败不留残留) |
| `tests/integration/test_retention_tool_pg.py` | 修改 | 全部用例角色化;删行数快照;补 `--table` 与最坏情况用例 |
| `tools/telemetry_retention.py` | 修改 | 新增 `--table`;PG 分支目标解析改为"显式限定名优先" |
| `tests/unit/test_retention_tool.py` | 修改 | `--table` 的参数分类用例(不连库) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 7 条用例迁出真表;`_RUN_PREFIX` 双职责拆分;其余 fixture 收敛到工厂 |
| `Makefile` | 修改 | `lint` / `check` 各加一道字面量门 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` | 修改 | `--table` 用法与 1.3.2 定版 |
---
## 跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
`tests/integration/conftest.py` 对外只有一个 fixture 与一个返回类型:
```python
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str # 已挂 options=-csearch_path=<schema>
bare_dsn: str | None # 同角色但不挂 search_path;role is None 时为 None
```
```python
async def pg_sandbox(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: Literal["none", "owner", "grantee"] = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox: ...
```
### 三种 `role` 的语义
覆盖现有全部六个 fixture 的需求,**不得再加第四种**:
| `role` | schema 属主 | `ddl`/`extra` 由谁执行 | 返回 DSN 的身份 | 对应今天的 fixture |
|---|---|---|---|---|
| `"none"` | admin | admin | admin | `fresh_schema` / `legacy_schema` / `pre_tenant_schema` / `partitioned_schema` |
| `"owner"` | 临时角色 | **临时角色自己**(故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
| `"grantee"` | admin | **admin**(故表属主 = admin,与最小权限现场一致) | 临时角色(只被 `GRANT USAGE ON SCHEMA` + 表级 `grants`**绝不 GRANT CREATE** | `least_privilege_dsn` / `least_privilege_pre_tenant_dsn` |
### `ddl` / `extra` 的执行契约
1. **调用方传的 DDL 一律不带 schema 限定**`CREATE TABLE llm_calls (...)`,不是 `CREATE TABLE {schema}.llm_calls`)。工厂在执行前对该连接 `SET search_path = <schema>`,由 search_path 定位。这条统一了两种今天并存的写法——`PG_DDL` 本就是裸表名,而 `_LEGACY_DDL` / `_PRE_TENANT_DDL` 今天带 `{schema}` 占位,**Task 5 要把这两个常量的 `{schema}.` 前缀去掉**。
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
3. `ddl is None` 时只建空 schema,不执行任何建表语句。
### 临时角色的 DSN 构造
- 密码:模块级常量(测试专用,非机密),沿用今天 `_PROBE_PASSWORD` 的做法。
- `bare_dsn`:把 admin DSN 里的 `//user:pass@` 段整体替换为 `//<role>:<密码>@``re.sub(r"//[^@/]+@", ...)``count=1`),**不追加任何 `options` 参数**——它的用途就是让 `search_path` 回落到 `"$user", public`
- `dsn`:在 `bare_dsn` 基础上追加 `options=-csearch_path%3D<schema>`,分隔符按 DSN 里是否已有 `?``?``&`
- `role="none"``dsn` 用 admin 身份加同样的 options`bare_dsn``None`——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
### 三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
1. schema 名 `pgw_s_<12 位 hex>`、角色名 `pgw_r_<12 位 hex>`,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`except BaseException` 时**逆序**执行并 re-raise`yield` 之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为 `DROP SCHEMA IF EXISTS <s> CASCADE``DROP OWNED BY <r>``DROP ROLE IF EXISTS <r>``DROP OWNED BY` 必须在 `DROP ROLE` 之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。
3. `role != "none"` 时先查 `rolcreaterole OR rolsuper`**在建任何对象之前** `pytest.skip``production_template` 的教训:`pytest.skip` 抛的是 `BaseException`,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
---
## Task 1:沙箱工厂
- [ ] **文件**`tests/integration/conftest.py`(新建)、`tests/integration/test_pg_sandbox.py`(新建)
**行为**:实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(`dotenv_values(".env")` 合并 `os.environ`,剥掉 `+driver`,缺则 `skip`,库名不以 `/polygateway` 结尾则 `pytest.fail`)——这段逻辑今天重复两份,本任务收敛为一份私有函数。
**测试要求(先红后绿的路径明确)**:先写 `test_pg_sandbox.py` 再写 `conftest.py`——此时 `pg_sandbox` fixture 不存在,pytest 报 `fixture 'pg_sandbox' not found`,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
| 用例 | 断言 |
|---|---|
| `role="none"` 建表 | 表落在 `sandbox.schema` 下;`sandbox.bare_dsn is None` |
| `role="owner"` 建表 | 表属主 = `sandbox.role``sandbox.role != sandbox.schema` 且两者前缀不同 |
| `role="owner"``bare_dsn` | `SHOW search_path``"$user", public`;用它解析 `llm_calls` 得到的**不是**沙箱里那张表 |
| `role="grantee"` | 该角色 `CREATE TABLE` 被拒(`asyncpg.exceptions.InsufficientPrivilegeError`),`INSERT` 正常 |
| **setup 中途失败** | 传一段必然报错的 `ddl`(如 `CREATE TABLE llm_calls (bad_type NOT_A_TYPE)`),捕获异常后查 `pg_namespace` / `pg_roles`:本次 uuid 对应的 schema 与角色**都不存在** |
| teardown 后无残留 | 在用例内部记下 `sandbox.schema` / `sandbox.role`,用一个**更外层**的 fixture(在 `pg_sandbox` 之后销毁)回查两者均已消失 |
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
```
预期全绿;随后手工查实例:`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'``pg_roles` 同款查询均为空。
---
## Task 2retention 测试角色化,删除行数快照
- [ ] **文件**`tests/integration/test_retention_tool_pg.py`(修改)
**必须在 Task 3 之前完成**——见文首 CAUTION。
**行为**
1. 删除 `_public_count``before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox`
2. **凡启动脚本的用例一律 `role="owner"`**(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5``'acme': 3`、批次 1/3 存在而批次 4 不存在、`已删除 5 行`、剩余 `fresh-1`/`fresh-2`、分区表退出 3 且含 `DROP PARTITION`/`DETACH`、缺 asyncpg 退出 2。
4. 新增设计 §5.3 的**最坏情况**用例:用 `sandbox.bare_dsn`、不给 `--table``--older-than-days 7 --apply`。断言退出 **2**、stderr 非空且含 `llm_calls`、沙箱表一行不少。**不断言 PG 的英文错误原文**(`lc_messages` 不由测试掌握),**测试代码里不得出现 `public.llm_calls` 字面量**。
**测试证据(这条用例没有常规先红路径,如实记录)**:让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 `findings/2026-08-26-issue18-shared-pg-test-isolation.md` §7 的探针 6/7 提供:同款临时角色对真表的 `COUNT``DELETE` 均返回 `InsufficientPrivilegeError`。**提交说明里必须写明这一点**,不得含糊成"已验证"。
其余改动的先红路径正常:删掉 `_public_count` 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
```
预期全绿;连跑三次结果一致。
---
## Task 3`--table` 参数与精确解析
- [ ] **文件**`tools/telemetry_retention.py`(修改)、`tests/unit/test_retention_tool.py`(修改)、`tests/integration/test_retention_tool_pg.py`(追加用例)
**顺序**:**先写测试再改脚本**——四条集成用例与五条单测在脚本未改时全部先红(`--table` 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
**脚本行为**(设计 §4):
| 项 | 要求 |
|---|---|
| 参数 | `--table SCHEMA.NAME`,仅 `--backend postgres` 接受 |
| 校验(全部退出 **1**) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 `.``"`**表名段不等于 `llm_calls`** |
| 解析 | 给了 `--table` 时用 `to_regclass($1)``"<schema>"."llm_calls"``_quote` 包裹),绕开 `search_path`;未给时维持今天的裸 `TABLE` 解析 |
| 解析不到 | 退出 **2**,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
| 无权限 | 后续 `COUNT``PostgresError`,走既有 except → 退出 **2**(不新增分支) |
| 分区表 | 仍退出 **3**,逻辑不动 |
| 提示行 | `--apply` 且**未**给 `--table` 时,在"目标表: x.y"之后打印一行,指出目标由 `search_path` 推断、可用 `--table` 钉死;dry-run 不打 |
`--help` 的 epilog 补两句:本脚本只清理 `llm_calls`;含点或引号的复杂标识符不支持,此时退回不给 `--table` 的路径。
**单测**`tests/unit/test_retention_tool.py`,不连库):`TestUsageErrors` 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 `--table``TestHelp` 加一条断言 epilog 点明表名固定为 `llm_calls`
**集成用例**`test_retention_tool_pg.py`,全部 `role="owner"`):
| 用例 | 构造 | 预期 |
|---|---|---|
| 显式指定成功 | `--table <sandbox.schema>.llm_calls` + `--apply` | 退出 0,删除结果与不给 `--table` 时逐条一致 |
| 指向不存在的 schema | `--table pgw_s_nosuchxxxxxxxx.llm_calls` | 退出 **2**,stderr 点名该表;沙箱表一行不少 |
| 指向无权的表 | 建两个 `role="owner"` 沙箱,用 A 的 DSN 指 B 的表 | 退出 **2**A、B 两张表都不变 |
| 指向分区表 | 分区沙箱 + `--table` | 仍退出 **3**,含 `DROP PARTITION` / `DETACH` 字样 |
| 提示行(设计验收 #2 | 沙箱 DSN + `--apply`**不给** `--table` | stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
**验证**
```
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
```
---
## Task 4:7 条用例迁出真表,`_RUN_PREFIX` 拆职责
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. 七条用例改用 `pg_sandbox(role="none")``TestObservabilityColumns::test_values_round_trip``TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows``test_aclose_idempotent``TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max`
2. `test_schema_has_frozen_columns_in_order``information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")``"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
**测试证据**:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)`),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
```
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
---
## Task 5:其余 fixture 收敛到工厂
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. `legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema` 改为 `role="none"``least_privilege_dsn``least_privilege_pre_tenant_dsn` 改为 `role="grantee"`
2. 按接口契约,`_LEGACY_DDL``_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
3. `production_template` **不收敛**:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
**测试证据**:这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
**验证**:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
---
## Task 6lint 门与字面量清理
- [ ] **文件**`Makefile`(修改)、`tests/integration/*.py`(注释措辞)
**行为**`lint``check` 各加一步——`tests/` 下命中字面量 `public.llm_calls``exit 1` 并打印命中行。注释与 docstring **同样不豁免**,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls`"。
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
**测试证据**:故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
**验证**
```
make lint && make check
```
---
## Task 7:独立验证(合并前硬门)
- [ ] 派**全新上下文**的 verifier subagent`verification-before-completion`),交给它设计 §11 的判据表逐条核对,重点:
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
- 判据 6:7 条用例迁移前后断言逐条对照
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
---
## Task 8:文档与版本号
- [ ] **文件**`README.md``CHANGELOG.md``pyproject.toml``src/polygateway/__init__.py`
- README`--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls`。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
- CHANGELOG:按设计 §10 如实写明 `tools/``tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
- 版本号两处一致改 `1.3.2`
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
---
## Task 9:发布 1.3.2
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff`)→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow`** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registrytoken 走 `TWINE_PASSWORD`,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
- [ ] 关闭 issue #18,正文指向本计划与设计。
---
## 审查留痕(Codex2026-08-26
报 5 项,**全部采纳**
| # | 意见 | 处置 |
|---|---|---|
| 1 | `ddl`/`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl`/`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table`(测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py`,此时 `fixture 'pg_sandbox' not found` 全红 |
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
## Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ \
--from "plan:2026-08-26-issue18-pg-test-isolation" \
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
@@ -0,0 +1,418 @@
# 实现计划: 推理档位一等化
- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准)
- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。
- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。
- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。
- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 |
| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 |
| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 |
| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 |
| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 |
| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 |
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) |
| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 |
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 |
| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 |
| `src/polygateway/__init__.py` | 修改 | 导出 `Effort``ThinkingWire` |
| `.env.example` | 修改 | 两个新键的模板与注释 |
| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 |
| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 |
| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 |
| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 |
| `tests/unit/test_telemetry.py``tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 |
| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) |
---
## 关键接口(跨任务消费,此处定稿)
```python
# types.py
class Effort(StrEnum):
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
```
```python
# providers.py
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
name: str
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
```
```python
# thinking.py
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...]
evidence: str
@property
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
@property
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
@property
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
@dataclass(frozen=True)
class ThinkingResolution:
"""注入片段 + **实际**生效档。
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
"""
payload: Mapping[str, Any]
applied_effort: Effort | None # Phase 1(不表态)为 None
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | None,
*,
model: str,
fallback: str = "error", # "error" | "nearest"
warn_unregistered: bool = True,
) -> ThinkingResolution: ...
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None: ...
```
```python
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
# SourceConfig: reasoning_effort: Effort | None = None
# effort_fallback: str = "error"
# ChatRequest: reasoning_effort: Effort | None = None
```
---
## Task 1 — `Effort` 词汇与能力表重构
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改)
**行为**:
1. `types.py` 新增 `Effort``_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。
2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`
3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。
4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py``ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py``can_disable` 处确认派生属性可用。
5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头):
| model | supported_efforts |
|---|---|
| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` |
| `glm-5.2` | `(NONE, HIGH, MAX)` |
| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` |
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` |
| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` |
| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` |
| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` |
| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` |
| `MiniMax-M3` | `(NONE, AUTO)` |
| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` |
| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` |
`claude-haiku-5``gemini-3-flash``kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort``(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered``(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)``(AUTO,)` 为假;空元组构造报 `ValueError`
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable``::test_cheapest_effort_skips_none``::test_is_tiered_excludes_none_and_auto``::test_empty_efforts_rejected`
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort``types.py` 不得产生反向依赖,设计 §13 第 6 条)
- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it`
---
## Task 2 — `ThinkingWire` 与 8 段 provider 表
**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改)
**行为**:
1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`
3. `DEFAULT_PROFILES` 由 4 段扩到 8 段:
| provider | off | on_base | effort_key |
|---|---|---|---|
| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` |
| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
`__init__.py` 同步导出 `ThinkingWire``openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`
**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。
**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered``::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS
- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own`
---
## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射
**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改)
**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | `effort is None` | 返回 `{}` |
| 2 | **该请求档所需的**形态未知(请求 `none``wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
| 4 | `effort is NONE``not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
| 5 | `effort not in supported_efforts``fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
**注入形态**:
- `effort is NONE``wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。
- `effort is AUTO``wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。
- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。
**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER``supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
**验收**: 五关各自触发与不触发;`medium``(LOW, HIGH, MAX)``nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。
**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing``::test_phase2_unknown_wire_points_to_register``::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model``::test_phase5_says_toggle_only_for_switch_model`
另: `::test_nearest_ties_go_cheaper``::test_none_never_maps``::test_auto_injects_on_base_only``::test_effort_key_none_rejects_tier``::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have`
---
## Task 4 — 源级配置入口
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改)
**行为**:
1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None``effort_fallback: str = "error"`
2. `config.py``_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")``"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。
3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。
4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking``reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True``reasoning_effort is NONE`;或 `enable_thinking is False``reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。
5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。
**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。
**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed``::test_invalid_effort_lists_vocabulary``::test_contradictory_thinking_flags_rejected``::test_consistent_flags_allowed`
**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS
- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself`
---
## Task 5 — 请求级入口与优先级
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
**行为**:
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`
2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`
3. 新增纯函数(放 `thinking.py`,与其余推理决策同处):
```python
def effective_effort(
*, request_effort: Effort | None, source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
```
语法糖映射: `True``Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False``Effort.NONE`;`None` → 不表态。
**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。
**测试**(先失败后通过): `::test_request_effort_wins_over_source``::test_none_request_does_not_clear_source``::test_enable_thinking_true_is_auto``::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to`
---
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
**行为**:
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72``tests/unit/test_backpressure.py:213``tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
**不动**: `EmbeddingTransport``OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort``ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
---
## Task 6 — 缓存 key
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
**行为**:
1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`
3. `CacheMW.__call__``request.reasoning_effort`
**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
**测试**(先失败后通过): `::test_request_tier_changes_key``::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`
**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS
- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled`
---
## Task 7 — 遥测新增 `reasoning_effort`
**文件**: `src/polygateway/telemetry/schema.py``src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
**行为**:
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
2. `ports.py`: `record_llm_call``reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
3. 两个 recorder 落库新列。
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt``applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407``ocr.py:451``None`(无推理语义)。三个 emit 入口取值口径分列:
| 入口 | 取值 | 理由 |
|---|---|---|
| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
`sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。
5. 值为 `Effort` 时取 `.value` 落库,`None``NULL`——与 `thinking_observation` 同一先例(`StrEnum``str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。
**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier``::test_cache_hit_records_request_tier_only``::test_absent_tier_is_null`
**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS
- [ ] 提交: `feat: record which tier a call actually ran at`
---
## Task 8 — transport 接线与对账
**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改)
**行为**:
1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body``overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
- `RetryMW``retry.py:375``TransportResult → LLMResponse` 转换处带上该字段。
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected``::test_no_retry_on_tier_error``::test_throttle_key_separates_tiers``::test_reconcile_none_vs_observed``::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct`
---
## Task 9 — 全套件、文档与 wiki
**文件**: `CHANGELOG.md``.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号)
**行为**:
1. `make lint` + `make test` 全绿;`make format`
2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。
3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
4. 版本号 **`1.3.3`**(2026-09-05 人类指令;不因破坏性变更走 minor),`pyproject.toml``src/polygateway/__init__.py` 两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。
**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
**验证**: `conda run -n PolyGateway make ci` → PASS
- [ ] 提交: `docs: cut 1.3.3 notes for the tier work`
---
## Task 10 — e2e 实测校正初始能力表(标 `slow`)
**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。
**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关)
- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does`
---
## 执行顺序与依赖
```
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
T2(wire) ─────────┘ ↑
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
│ │
└─→ T6(缓存 key) ↓
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
```
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。
+15
View File
@@ -0,0 +1,15 @@
---
type: plan
node_id: plan:reasoning-effort
title: "实现计划: 推理档位一等化"
date: 2026-09-05
---
# 实现计划: 推理档位一等化
正文: `2026-09-04-reasoning-effort.md`(378 行,10 任务)。实现 `design:reasoning-effort`
- **拆分逻辑**: T1(`Effort` 词汇 + 能力表)与 T2(`ThinkingWire` + 8 段 provider 表)可并行 → T3(五道关卡 + nearest 映射)→ T4(源级 env 入口)→ T5(请求级入口与优先级)→ T6(缓存 key 两处)/T7(遥测第 26 列)→ T8(transport 接线与告警节流)→ T9(CHANGELOG/wiki/1.4.0)→ T10(经 new-api 逐模型实测,标 `slow`)。
- **T10 单列的理由**: 人类定「能力表数据统一自己经 new-api 实测」。T1 落的是文档推定值(四方交叉: 官方文档/OpenRouter/cherry-studio/LiteLLM),不实测则整张表都是假设——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档,中转改档位有第三方证据。
- **执行方式**: 直接按计划实现,**不派** `subagent-driven-development`——10 个任务共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型,独立上下文的 subagent 容易在签名上分叉。
- **保真校验**: 不适用(`thinking.py` 系库自研,非 `reference/` 移植蓝本;且三项目当前不在工作区)。
+59 -3
View File
@@ -1,11 +1,11 @@
---
type: schema
node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 22 字段)"
title: "表结构: llm_calls(遥测 26 字段)"
date: 2026-07-20
---
# 表结构: llm_calls(遥测 22 字段)
# 表结构: llm_calls(遥测 26 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -28,6 +28,10 @@ date: 2026-07-20
| 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 = **本次调用**未上报 |
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -54,7 +58,9 @@ 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)。
`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)。
> **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
@@ -77,6 +83,56 @@ SELECT DISTINCT sampling FROM llm_calls
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
```
## 推理观测口径(2026-08-25,issue #16/#17)
`thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0``observed`;`reasoning_tokens == 0``absent`(上游明确上报未推理);两个信号双缺为 `unknown`
**`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
```sql
SELECT model,
thinking_observation,
count(*) AS calls,
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct
FROM llm_calls
WHERE cache_hit = false AND error IS NULL
AND created_at >= now() - interval '7 days'
GROUP BY model, thinking_observation
ORDER BY model, calls DESC;
```
三条限定各有理由: `cache_hit = false``cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
## 推理档位口径(2026-09-04,issue #20)
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
| 入口 | 有生效源? | 记什么 |
|---|---|---|
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
| `emit_cache_hit` / `emit_terminal_failure` | 无 | 仅 `request.reasoning_effort` |
成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
按档位看推理产出,即压测「高档是不是真的多想」的基本查询:
```sql
SELECT model, reasoning_effort,
count(*) AS calls,
round(avg(reasoning_tokens)) AS avg_reasoning_tokens
FROM llm_calls
WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL
GROUP BY model, reasoning_effort
ORDER BY model, calls DESC;
```
## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+28 -2
View File
@@ -22,9 +22,24 @@ from polygateway.errors import (
)
from polygateway.ocr import OcrClient
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
from polygateway.providers import (
DEFAULT_PROFILES,
ProviderProfile,
ThinkingWire,
register_provider,
)
from polygateway.telemetry.schema import telemetry_schema_sql
from polygateway.thinking import (
ThinkingCapability,
ThinkingResolution,
ThinkingUnsupportedError,
get_capability,
register_capability,
resolve_thinking,
)
from polygateway.types import (
EFFORT_ORDER,
Effort,
EmbeddingResponse,
LLMResponse,
OcrLayoutElement,
@@ -32,12 +47,15 @@ from polygateway.types import (
OcrTextResult,
SourceConfig,
TelemetryStatus,
ThinkingObservation,
)
__version__ = "1.3.0"
__version__ = "1.3.3"
__all__ = [
"DEFAULT_PROFILES",
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted",
"CircuitOpenError",
"EmbeddingClient",
@@ -63,9 +81,17 @@ __all__ = [
"SourceDeadError",
"SourceNotConfiguredError",
"TelemetryStatus",
"ThinkingCapability",
"ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError",
"ThinkingWire",
"TransientError",
"__version__",
"gather_bounded",
"get_capability",
"register_capability",
"register_provider",
"resolve_thinking",
"telemetry_schema_sql",
]
+56 -11
View File
@@ -26,7 +26,7 @@ from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.ports import TelemetryStatusProvider
from polygateway.pricing import PricingTable
from polygateway.providers import get_capability, get_provider, resolve_thinking
from polygateway.providers import get_provider
from polygateway.sources import (
AdaptivePacer,
HealthAwareSelector,
@@ -34,11 +34,14 @@ from polygateway.sources import (
RoundRobinSelector,
SourceCooldownMemo,
)
from polygateway.thinking import effective_effort, get_capability, resolve_thinking
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
TelemetryStatus,
coerce_effort,
validate_caller_dimensions,
validate_request_overlay,
)
@@ -58,7 +61,8 @@ if TYPE_CHECKING:
TelemetryRecorder,
Transport,
)
from polygateway.providers import ProviderProfile, ThinkingCapability
from polygateway.providers import ProviderProfile
from polygateway.thinking import ThinkingCapability
from polygateway.types import (
BackpressurePolicy,
RetryPolicy,
@@ -81,20 +85,36 @@ def _guard_thinking(
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
source.enable_thinking,
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
effective_effort(
request_effort=None,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback,
)
def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
"""单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动
`reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正
改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应
两者取值域不相交(`"none"`/`"low"` vs `true`/`false`),故追加进同一个列表也
不会把两种写法摘要成同一身份
"""
parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None:
parts.append(source.enable_thinking)
if source.reasoning_effort is not None:
parts.append(source.reasoning_effort)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
@@ -102,16 +122,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
缓存的旧响应全源者皆未表态时字面量与历史实现逐字相同,不触发存量
缓存冷启动
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
源级 `reasoning_effort`(issue #20)同理: 它一旦真正改变请求体,"关掉推理后
重启"就会读到开着推理时缓存的旧响应全源者皆未表态时字面量与历史实现逐字
相同,不触发存量缓存冷启动
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
后者由 `build_cache_key` `reasoning_effort` 参数单独承担(ARCH §7.5)
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
# 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要:
# 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
# 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项
# 的源根本进不了 marks,`_fingerprint_mark` 改了也白改
marks = sorted(
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
{
_fingerprint_mark(s)
for s in sources
if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None
}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
@@ -287,6 +316,7 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None,
reasoning_effort: Effort | str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> LLMResponse:
@@ -296,6 +326,11 @@ class GatewayClient:
高于源级 `extra_body`低于结构化输出的注入带默认值的 keyword-only
参数不影响既有调用点(issue #4)。
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
`ENABLE_THINKING`(设计 §4.2)`None` **不表态**(随源级配置),
`Effort.NONE`("要求不推理")严格区分裸字符串(`"low"`)也收,在此归一成
`Effort`,非法值当场 `ValueError` `SourceConfig` 那条装配路同口径
`tenant_id` `meta` 是调用方自定义维度,只进遥测**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS可进复合索引),后者是任意 KV 容器(issue #11)。
@@ -315,6 +350,15 @@ class GatewayClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
)
# 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串
# 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类,
# 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律
# 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱
effort = (
None
if reasoning_effort is None
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
)
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -325,6 +369,7 @@ class GatewayClient:
stream=stream,
overlay=sampling,
sampling=sampling,
reasoning_effort=effort,
tenant_id=dimension_tenant_id,
meta=dimensions,
)
+10
View File
@@ -25,6 +25,7 @@ from polygateway.types import (
GlobalLimits,
RetryPolicy,
SourceConfig,
coerce_effort,
)
if TYPE_CHECKING:
@@ -43,6 +44,11 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
"ENABLE_THINKING": ("enable_thinking", "bool"),
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
"REASONING_EFFORT": ("reasoning_effort", "effort"),
# 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str"
"EFFORT_FALLBACK": ("effort_fallback", "str"),
"MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"),
@@ -93,6 +99,10 @@ def _cast(raw: str, kind: str, key: str) -> object:
if lowered in ("0", "false", "no", "off"):
return False
raise ValueError(f"非法布尔值: {raw!r}")
if kind == "effort":
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
return coerce_effort(raw, origin="")
if kind == "json":
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
parsed = json.loads(raw)
+3
View File
@@ -411,6 +411,9 @@ class EmbeddingClient:
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
reasoning_applies=False,
)
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
+70 -1
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from polygateway.types import ChatRequest, LLMResponse
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -28,6 +28,54 @@ _KEY_PREFIX = "pgw:cache:"
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
def _coerce_observation(raw: Any) -> ThinkingObservation:
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
方向选择的理由: `_rehydrate` JSON 里的**新字段**已经是宽容的(先按
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的真实场景是
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
这些条目判成未命中,就会每次真打网关随后覆写回旧值,两个版本互相打对方的
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)一个纯
可观测性字段不该有能力废掉内容完好的缓存响应"整条作废"留给真正破坏内容
完整性的失败(JSON 坏了结构化重建不过)
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
读不懂的取值,这是唯一诚实的说法
"""
try:
return ThinkingObservation(raw)
except ValueError:
logger.warning(
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
raw,
)
return ThinkingObservation.UNKNOWN
def _coerce_applied_effort(raw: Any) -> Effort | None:
"""缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。
`_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis ,
先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中,
两个版本就会互相打对方的缓存归因字段不该有能力废掉内容完好的响应
降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂
的取值这是唯一诚实的说法随便挑一档等于替上游声称了一件它没说过的事
"""
if raw is None:
return None
try:
return Effort(raw)
except ValueError:
logger.warning(
"缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的"
"进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存",
raw,
)
return None
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
@@ -58,12 +106,21 @@ def build_cache_key(
salt: str | None,
*,
sampling: Mapping[str, Any] | None = None,
reasoning_effort: Effort | None = None,
) -> str:
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
`sampling` **非空**时参与( salt "仅非 None"不同空串是有意义的
salt,而空采样参数与不传无语义差别)它必须进 key: 否则同 messages 5
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
`reasoning_effort` **请求级**档位(issue #20),仅非 `None` 时参与。它不能靠
`model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,一次调用改档位不会
让它变一个字节;不进 key 则同 messages low max 互相命中, issue #4
5 seed 全命中同一响应的逐字翻版
判据用 `is not None` 而非真值: `Effort.NONE`(明确要求不推理) `None`
(不表态)语义不同前者拿到的是没有推理过程的响应,合并即毒化
"""
key_obj: dict[str, Any] = {
"model": model_fingerprint,
@@ -74,6 +131,8 @@ def build_cache_key(
key_obj["salt"] = salt
if sampling:
key_obj["sampling"] = dict(sampling)
if reasoning_effort is not None:
key_obj["reasoning_effort"] = str(reasoning_effort)
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -114,6 +173,9 @@ class CacheMW:
namespace,
request.cache_salt,
sampling=request.sampling,
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
reasoning_effort=request.reasoning_effort,
)
cached = await self._safe_get(key)
if cached is not None:
@@ -132,6 +194,13 @@ class CacheMW:
data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
structured_data = self._rebuild_structured(fields.get("content", ""), request)
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
fields.update(
cache_hit=True,
latency_ms=0,
+10
View File
@@ -285,6 +285,9 @@ class RetryMW:
stream=request.stream,
overlay=request.overlay,
call_id=call_id,
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
# 自己按选中的源解析,两者在 effective_effort 里汇合)
reasoning_effort=request.reasoning_effort,
)
if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
@@ -390,6 +393,11 @@ class RetryMW:
cached_prompt_tokens=result.cached_prompt_tokens,
model_reported=result.model_reported,
reasoning_tokens=result.reasoning_tokens,
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
thinking_observation=result.thinking_observation,
# 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉),
# 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档
applied_effort=result.applied_effort,
)
async def _emit(
@@ -413,6 +421,8 @@ class RetryMW:
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
reasoning_applies=True,
)
except asyncio.CancelledError:
raise
+132 -3
View File
@@ -23,7 +23,8 @@ from polygateway.errors import (
SourceNotConfiguredError,
)
from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
from polygateway.thinking import effective_effort
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
@@ -55,6 +56,95 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
def _normalize_observation(raw: object) -> str:
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
(尤其迁移期的测试替身) `LLMResponse(..., thinking_observation="observed")`
完全自然`==` 比较照常成立, `.value` 会当场抛 `AttributeError`, `_record`
`except Exception` 吞成一条泛化 warning 丢的不是这一列,**整行**,
"遥测必录"是铁律
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
落到同一个 `except` 同样丢整行,那只修好了裸 str 一半(口误值对测试替身
一样自然)降级到 `unknown` 是诚实的库确实判不出这个取值的含义,而单独
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)
"""
try:
return ThinkingObservation(raw).value
except ValueError:
logger.warning(
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
raw,
)
return ThinkingObservation.UNKNOWN.value
def _normalize_effort(raw: object) -> str | None:
"""实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。
**不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse`
是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然,
`.value` 会当场抛 `AttributeError`, `_record` `except Exception` 吞成
一条泛化 warning 丢的不是这一列,**整行**
域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致
(设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名,
归因字段不该有能力废掉一整行遥测降级到 `None` 也是唯一诚实的说法库确实
不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事
注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`:
`'none'` "明确要求不推理",是一次表态
"""
if raw is None:
return None
try:
return Effort(raw).value
except ValueError:
logger.warning(
"推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL"
"(其余列照常落库)",
raw,
)
return None
def _attempt_effort(
*,
request: ChatRequest,
source: SourceConfig,
response: LLMResponse | None,
applies: bool,
) -> str | None:
"""一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。
成功行一律读 `response.applied_effort` **绝不重算**: 源上开了
`EFFORT_FALLBACK=nearest` ,请求 `medium` 而模型只有 low/high/max,实发的是
`low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出
过的分组下而两个值在没开映射的源上恒等,这个错在本地跑不出来
失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射
的源上,成功行是映射后的档失败行是请求档,两种行不是同一把尺子仍然记而不是
留空,是因为档位错误(`resolve_thinking` Phase 2/4/5)根本没发 HTTP 就被拒,
这类行记的正是**被拒绝的那一档**"哪一档配错了"是压测与排障要的信号
回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态
(语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"
"""
if not applies:
return None
if response is not None:
return _normalize_effort(response.applied_effort)
return _normalize_effort(
effective_effort(
request_effort=request.reasoning_effort,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
)
)
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap:
@@ -111,6 +201,9 @@ class _AttemptUsage:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -128,11 +221,12 @@ class _AttemptUsage:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
)
class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
@@ -163,8 +257,17 @@ class TelemetryEmitter:
latency_ms: int,
response: LLMResponse | None,
error: str | None,
reasoning_applies: bool,
) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
`reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为
`True`,embedding / OCR 路径为 `False`它不能由 emitter 自己推断三条路径
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` embedding
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
**不设默认值**: `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
当场 TypeError,好过被静默当成"没表态"
"""
usage = _AttemptUsage.of(response)
await self._record(
request=request,
@@ -185,10 +288,14 @@ class TelemetryEmitter:
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
thinking_observation=usage.thinking_observation,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id,
meta=request.meta,
reasoning_effort=_attempt_effort(
request=request, source=source, response=response, applies=reasoning_applies
),
)
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
@@ -214,6 +321,8 @@ class TelemetryEmitter:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
thinking_observation=response.thinking_observation,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
@@ -222,6 +331,9 @@ class TelemetryEmitter:
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id,
meta=request.meta,
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
reasoning_effort=_normalize_effort(request.reasoning_effort),
)
async def emit_terminal_failure(
@@ -247,11 +359,15 @@ class TelemetryEmitter:
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
thinking_observation=ThinkingObservation.UNKNOWN,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id,
meta=request.meta,
# 可能根本没选出源,故与 sampling 同样只取请求档
reasoning_effort=_normalize_effort(request.reasoning_effort),
)
async def _record(
@@ -276,9 +392,17 @@ class TelemetryEmitter:
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
thinking_observation: ThinkingObservation,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
reasoning_effort: str | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -328,6 +452,11 @@ class TelemetryEmitter:
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
# Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation),
reasoning_effort=reasoning_effort,
)
except asyncio.CancelledError:
raise
+2
View File
@@ -455,6 +455,8 @@ class OcrClient:
latency_ms=latency_ms,
response=response,
error=error_text,
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
reasoning_applies=False,
)
@staticmethod
+21 -2
View File
@@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable
from .types import (
ChatRequest,
Effort,
EmbeddingTransportResult,
LLMResponse,
OcrLayoutResult,
@@ -36,7 +37,16 @@ class Middleware(Protocol):
@runtime_checkable
class Transport(Protocol):
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。"""
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)
该参数**不设默认值**, `TelemetryRecorder.record_llm_call` 同一既有约定:
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
"调用方没表态"一次本该报错的漏配就此变成一次悄悄涨价的调用
"""
async def complete(
self,
@@ -46,6 +56,7 @@ class Transport(Protocol):
stream: bool,
overlay: dict[str, Any],
call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult: ...
@@ -260,13 +271,19 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
"""遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning
`tenant_id` `meta` 到达 recorder **已由 emitter 归一化**`tenant_id`
`None` 已转空串,`meta` 已序列化为 JSON 字符串( dict `'{}'`)
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
的裸 `str`(`StrEnum` `str` 子类, asyncpg 的参数编码对子类不保证接受,
遥测写失败又只降级成 warningPG 那一路会静默少一列数据)
`reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value`
的裸 `str`,`None` 表示调用方没表态它与 `'none'`(明确要求不推理)不可折叠
recorder 只负责落库,不做任何语义判断, `sampling` 列由
`canonical_sampling_json()` emitter 侧定型是同一先例
"""
@@ -298,4 +315,6 @@ class TelemetryRecorder(Protocol):
reasoning_tokens: int | None,
tenant_id: str,
meta: str,
thinking_observation: str,
reasoning_effort: str | None,
) -> None: ...
+125 -180
View File
@@ -3,6 +3,9 @@
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
**精确匹配**,未注册即装配期报错注册是纯函数返回新表,不修改共享
状态( asyncio 中立铁律);client `registry` 参数持有自己的表
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么响应算不算推理,
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)
"""
from collections.abc import Mapping
@@ -10,221 +13,163 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class ThinkingWire:
"""一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。
三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的
那种静默失效:
============== ==========================================================
``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"",配了开关
即装配期报错并指路 `register_provider`/`extra_body`
``off=None`` 已知开启形态,**没有关闭形态**( provider 关不掉)
``effort_key`` ``None`` = provider 只有开关没有档位(qwen 系靠
``=None`` ``thinking_budget`` 调深度,不是档位)
============== ==========================================================
`on_base={}` `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加),
后者是"不知道怎么表达"
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
故需要 closed operation 集合与 `budgetWire` 代际变体本库只有一个 OpenAI 兼容
transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent),
一个协议一层形态即够(P1 YAGNI)
"""
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
"""单个 provider 的能力与差异声明。
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking`
True/False 时并入请求体的参数片段(`enable_thinking` None 时二者都不
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
标签(qwen );supports_native_schema D14 阶梯选择原生 response_format
`thinking` 声明推理参数的**形态**( provider ,数年不变一次);
`strip_think_tags` 声明响应 content 需剥离 ``<think>`` 标签(qwen );
`supports_native_schema` D14 阶梯选择原生 response_format
两档各有三种取值,**语义互不重叠**(issue #5):
========== ==========================================================
``{...}`` 已知的注入片段
``{}`` 已知**无需注入**任何参数即处于该档
``None`` **未知**: 本库不知道该 provider 如何表达这一档
========== ==========================================================
`None` `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
minimax/openai 源静默失效调用方以为关掉了推理,实际什么都没发生
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`
: 本类只声明**形态**(参数长什么样, provider );某个具体模型能否
关闭推理属**能力**( model ), `ThinkingCapability`
: 本类只声明**形态**(参数长什么样, provider );某个具体模型支持哪些
档位属**能力**( model ), `thinking.ThinkingCapability`二者合一在
provider 级表达不了代际差异glm-5.2 能关而 glm-5.3 不能,形态却完全相同
"""
name: str
thinking_on: Mapping[str, Any] | None
thinking_off: Mapping[str, Any] | None
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM /
# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`,
# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。
DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
{
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
"qwen": ProviderProfile(
name="qwen",
thinking_on={"enable_thinking": True},
thinking_off={"enable_thinking": False},
thinking=ThinkingWire(
off={"enable_thinking": False},
on_base={"enable_thinking": True},
# 百炼的深度控制是 `thinking_budget`(token 预算)而非档位;
# 预算型控制本库当前不支持(设计 §11 明确不做)
effort_key=None,
),
strip_think_tags=True,
),
# 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度,
# V4 一代两者并用(deepseek-v4-* 的档位见能力表)
"deepseek": ProviderProfile(
name="deepseek",
thinking_on={"thinking": {"type": "enabled"}},
thinking_off={"thinking": {"type": "disabled"}},
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
"openai": ProviderProfile(
name="openai",
thinking_on=None,
thinking_off=None,
# issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为
# {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type
# 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能)
"zhipu": ProviderProfile(
name="zhipu",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# 注入形态出处: 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`(优先级高于本片段)
# kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态
"moonshot": ProviderProfile(
name="moonshot",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
# 194),reasoning_effort 才是真开关——本段形态据此成立。
# `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
# 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
# 故选哪档对效果无差别。
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
"minimax": ProviderProfile(
name="minimax",
thinking_on={"reasoning_effort": "medium"},
thinking_off={"reasoning_effort": "none"},
thinking=ThinkingWire(
off={"reasoning_effort": "none"},
on_base={"reasoning_effort": "medium"},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标
# None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort`
# 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的
# 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走
# register_provider 注册,而不是挂在本段下
"openai": ProviderProfile(
name="openai",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
strip_think_tags=False,
),
# Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系
# 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的
# budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent
# 层吸收,本库只发 OpenAI 形态(设计 §3.4)
"anthropic": ProviderProfile(
name="anthropic",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
strip_think_tags=False,
),
"google": ProviderProfile(
name="google",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
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:
+19 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 24 INSERT 字段 +
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 26 INSERT 字段 +
`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处
本模块只依赖标准库: `telemetry/` `backends/``transports/``structured/` 同层且
互不依赖(import-linter 契约执法)
@@ -50,7 +50,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}'
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT
);
"""
@@ -80,7 +82,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
);
"""
@@ -95,6 +99,12 @@ SQLITE_BACKFILL = (
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
("thinking_observation", "TEXT"),
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -107,6 +117,9 @@ _PG_BACKFILL_DECLS = (
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
# 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -143,6 +156,8 @@ COLUMNS = (
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
)
_COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
"""写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪), `self._insert`
占位符同序两者必须一起改,分开改就是把值写进错位的列
+759
View File
@@ -0,0 +1,759 @@
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
`providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放
与查找),这里是**决策**(拿声明和响应做判断)P7"决策逻辑与状态存储分离"
本模块**不定义** `ThinkingObservation` 它是 `LLMResponse` 的字段类型,归最
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)
"""
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from loguru import logger
from polygateway.providers import ProviderProfile, ThinkingWire
from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。
推理正文是事实本身,`reasoning_tokens` 是对事实的转述转述缺失时事实仍然
作数2026-08-25 实测: MiniMax 这一路已不再返回
`usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文;
只认 token 数的判据会把这种情形误判成"没推理"
正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)
判不出来时返回 `UNKNOWN` 而非 `ABSENT`**不许把"没看见"说成"没发生"**
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
# 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏
# 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本
# 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会
# 给出一个方向相反的强结论(P5)
if reasoning_tokens is None or reasoning_tokens < 0:
return ThinkingObservation.UNKNOWN
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
class ThinkingUnsupportedError(ValueError):
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
`ValueError` 的子类而非 `errors.py` 四分类之一它描述的是**配置**
不可满足(装配期就该炸),不是一次调用的运行时失败transport 在请求期
捕获它并翻译为 `RequestRejectedError` 再进四分类单列一个类型是为了让
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
错误误贴成"推理开关无法满足")
"""
@dataclass(frozen=True)
class ThinkingCapability:
"""某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。
`ProviderProfile` 的分工: 后者声明**形态**(参数长什么样, provider ,
数年不变一次),本类声明**能力**( model ,同一 provider 每代都变)二者
合一在 provider 级表达不了代际差异实测 MiniMax-M3 可关闭推理,而同厂的
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型
**档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了
"关不掉但能调到最低档"这第三种情况 GLM-5.3 系与 Gemini 3 Pro 都是它
现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个
派生量一律不存字段,存了必与清单漂移
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它
文档推定与实测必须在 evidence 里说清楚是哪种前者会被 new-api 中转改写
(LiteLLM 里同一个 kimi-k3 `moonshot/` 下三档`perplexity/` 下六档)
"""
supported_efforts: tuple[Effort, ...]
evidence: str
def __post_init__(self) -> None:
"""构造期校验: 空清单与重复档都是登记错误,不能等到请求期才炸。"""
if not self.supported_efforts:
raise ValueError("supported_efforts 至少要有一档: 空清单表达不了任何能力")
if len(set(self.supported_efforts)) != len(self.supported_efforts):
raise ValueError(f"supported_efforts 有重复档: {self.supported_efforts}")
@property
def can_disable(self) -> bool:
"""能否关闭推理 = `none` 在不在清单里(旧 `can_disable` 字段的等价物)。"""
return Effort.NONE in self.supported_efforts
@property
def cheapest_effort(self) -> Effort | None:
"""除 `none` 外最省的一档;关不掉时作为**可执行替代**推荐给调用方。
`AUTO` 参与候选(纯开关型模型只有它可推荐),但因不在 `EFFORT_ORDER` ,
仅当没有任何强度档时才被选中全清单只有 `none` 时返回 None那种模型
没有"最省的开启档"可言
"""
tiers = [e for e in EFFORT_ORDER if e is not Effort.NONE and e in self.supported_efforts]
if tiers:
return tiers[0]
return Effort.AUTO if Effort.AUTO in self.supported_efforts else None
@property
def is_tiered(self) -> bool:
"""是否档位型(除 `none`/`auto` 外仍有强度档)。
用途是**告警文案**: 对纯开关型模型说"可选档位: ..."是错的,它没有档位
"""
return any(e not in (Effort.NONE, Effort.AUTO) for e in self.supported_efforts)
# 证据分三类,evidence 里必须自报家门:
# 实测 = 经 new-api 中转打过真实请求(最硬,不得被文档推定覆盖);
# 文档推定 = 官方文档 / OpenRouter / cherry-studio / LiteLLM 四方交叉;
# 实测未覆盖 = T10 试过但拿不到数据(渠道限额/上游报错/被路由到别的模型),
# 此时**必须写明原因**——"没测到"与"测了没问题"是两回事,T9 之类的下游
# 文档任务不得把前者写成后者。
_MEASURED = "2026-08-02 经 new-api 中转实测"
_T10 = "2026-09-05 经 new-api 中转实测(T10: 短提示词 N=5,声称可关的再加长上下文 N=3 复核)"
_DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉)"
# T10 的三条判据(报告见 tests/outputs/thinking/,用例见 tests/e2e/test_thinking_live.py):
# ① 关闭方向要求**每轮**未观测到推理,任一轮观测到即证伪;
# ② 短提示词下的"关掉了"必须过长上下文复核——glm-5.3-flash 正是短提示词 5/5
# 未观测到推理、5000 token 长上下文下 2/3 轮露馅(issue #20 的原始现象);
# ③ 上游整片不回传推理信号(kimi/MiniMax/qwen/gpt 这几路的关闭档都是)时,
# "没看见"不算"没发生",另取一个无魔数锚点: 关闭档的 completion_tokens
# 必须严格小于 max 档。
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{
# —— MiniMax ——
"MiniMax-M3": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理,"
"completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 "
"全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
"high 62-128 / xhigh 58-118 / max 57-170)——**它们是''的六种写法,不是六个深度档**,"
"MiniMax 官方只有开/关两态,配哪一个都一样贵。"
"**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}"
"(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」"
"(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表"
"堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'},"
"存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。"
"本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。"
f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型"
"无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。"
"另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**"
"——开启档 rt 有值,只有关闭档整片缺 details"
),
),
"MiniMax-M2.7": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符),"
"**关不掉**成立;auto 档 5/5 观测到推理。"
f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} "
"各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段"
),
),
"MiniMax-M2.5": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;"
"auto 档 5/5 观测到推理(rt 121-245)。"
f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理"
),
),
# —— qwen(百炼系,开关型) ——
"qwen3.7-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,且与 auto 档 completion 锚点可分);"
"auto 档 5/5 观测到推理。无强度档: 该 provider 的 wire 没有 effort_key,请求 max 当场被库"
"拒(百炼靠 thinking_budget 调深度,预算型控制本库不支持)。"
f"历史({_MEASURED}): enable_thinking=false 关闭(completion 5 token)"
),
),
"qwen3.7-max": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: 同 qwen3.7-plus——none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.6-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.5-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen-plus-latest": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
# —— deepseek ——
"deepseek-v4-pro": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,与 max 档锚点可分);"
"high / max 各 N=5 全部观测到推理(rt 59-73 / 56-69,推理正文 max 档明显更长: "
"135-186 vs 101-124 字符)。默认档按官方 thinking_mode 文档为 high"
),
),
"deepseek-v4-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 16-36 / 12-42)。与 v4-pro 同档,印证官方'与 deepseek-v4-pro 一致'的说法"
),
),
"deepseek-v4-flash-vision-exp": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 12-17 / 17-34)"
),
),
# —— 智谱 ——
"glm-5.3": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭已实测坐实**——请求 none(注入 thinking:{{type:disabled}})后 "
"5 轮里 4 轮仍观测到推理(rt 7、推理正文 12 字符),只有 1 轮 rt=0;"
"low / high / max 各 N=5 全部观测到推理(rt 55-77 / 47-63 / 48-60,三档分不出深浅)。"
"这一条了结了 issue #20 的核心争议: 当时短提示词下 rt≈1.2 看着像关掉了,实为采样噪声。"
f"文档侧三源一致({_DOC}): 智谱官方 thinking.type 只接受 enabled、迁移建议改用 "
"enabled + reasoning_effort=low,cherry-studio 标 toggle:false,OpenRouter 标 mandatory:true。"
"默认 max。**注意本渠道不校验档位值**: 未登记的 medium 也会被照单接受(实测 rt 62),"
"'网关没报错'在这一路上不构成'该档受支持'的证据"
),
),
"glm-5.3-flash": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭,且是判据②唯一的现役样本**——请求 none 时短提示词 5/5 轮"
"未观测到推理(看着完全像关掉了),换成 5000 token 长上下文后 3 轮里 2 轮露馅"
"(rt=2、有推理正文)。只跑短提示词的实测会在这个模型上得出相反结论。"
"low / high / max 各 N=5 全部观测到推理(rt 8-60 / 27-91 / 27-70)。默认 max"
),
),
"glm-5.2": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。"
"**T10 实测未覆盖——该渠道把本型号路由到了别的模型**: 请求 glm-5.2 时 5/5 轮回报 "
"model=glm-5.3(issue #20 记录的 6/6 复现),拿到的行为不属于本型号,故整组数据作废、"
"本行仍是文档推定。**下游风险**: 在本渠道上给 glm-5.2 配 none,库会照本行放行,"
"而真正服务请求的 glm-5.3 关不掉推理——运行期 reconcile 会喊,但那是事后"
),
),
"glm-5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true。"
"**T10 实测未覆盖**: 与 glm-5.2 同因——5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-5.1": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)。"
"**T10 实测未覆盖**: 5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-4.6v": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立,且是全表**证据最硬**的一条——短 5/5 + 长 3/3 全部裁定 ABSENT"
"(上游明确上报 reasoning_tokens=0,不是'看不见'),无需锚点旁证;"
"auto 档 5/5 观测到推理(rt 57-153)。model_reported 与请求一致,未被路由"
),
),
# —— 月之暗面 ——
"kimi-k3": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **可关闭——推翻 T1 的保守登记**。请求 none(注入 thinking:{{type:disabled}})后"
"短 5/5 + 长上下文 3/3 轮无任何推理信号,completion 恒 9 token;同一模型 max 档 "
"completion 明显更大且带推理正文(rt 33-146),锚点可分——故'没看见'这次有正面证据支撑。"
"两源分歧由此了结: OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列。"
"low / high / max 各 N=5 全部观测到推理(rt 21-53 / 38-60 / 33-146)。"
"**model_reported 是 `k3`**(别名,非串台)。官方提示切换档位会使 prefix cache 失效,"
"不宜在会话中途改档"
),
),
"kimi-for-coding": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: 本型号在 T1 时因'档位清单无直接证据'走 Phase 3 不登记(设计 §8 第三档),"
"现有它自己的实测证据故补登。none: 短 5/5 + 长 3/3 无推理信号、completion 恒 2 token,"
"与开启档锚点可分;low / high / max 各 N=3 全部观测到推理(rt 8-40 / 25-75 / 62-85)。"
"档位词汇沿用月之暗面官方的 low/high/max: 本渠道对 moonshot **不校验档位值**"
"(minimal/medium/xhigh 照样返回 200 并推理),故'没被拒'不构成'受支持',"
"登记一个厂商没声明的档等于替它做承诺"
),
),
# —— OpenAI ——
"gpt-5.4": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
evidence=(
f"{_DOC}: OpenRouter 登记 none/low/medium/high/xhigh,默认 medium;LiteLLM 登记 minimal 不支持。"
"**T10 实测未覆盖**: 该渠道本型号所有账号限流(429 All available accounts are "
"currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与本清单逐字相符"
),
),
"gpt-5.5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
evidence=(
f"{_T10}: 清单**逐条对上**,是全表验证最完整的一行。none 关闭成立(短 5/5 + 长 3/3,"
"锚点可分);low/medium/high/xhigh 各 N=5 全部观测到推理,且 rt 随档位单调上升"
"(18-21 / 18-22 / 22-34 / 35-65)——本渠道上少见的、档位真的分得开的模型;"
"清单外的 max 与 minimal 各 N=3 全部被上游 400 拒("
"Unsupported value),说明这一路**会校验档位值**,与 zhipu/moonshot 的照单全收相反"
),
),
# —— Anthropic ——
"claude-opus-5": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_DOC}: Anthropic 官方 adaptive thinking + output_config.effort 五档(low/medium/high/"
"xhigh/max),默认 high;OpenRouter 标 mandatory:false 故可关。"
"**T10 实测未覆盖**: 该渠道 claude 全系返回 429「api key 7天限额已用完」,5/5 轮失败。"
"关闭档仍依赖 new-api 把 reasoning_effort=none 转成 thinking 关闭形态,未经验证"
),
),
"claude-sonnet-5": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_DOC}: 同 claude-opus-5(OpenRouter supported_efforts 与默认档一致)。"
"**T10 实测未覆盖**: 同因 429「api key 7天限额已用完」"
),
),
# —— Google ——
"gemini-3.1-pro": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.MEDIUM, Effort.HIGH),
evidence=(
f"{_DOC}: **推理不可关闭**——Google 官方文档明确 Gemini 3 Pro / 3.1 Pro 无法关闭思考,"
"OpenRouter 亦标 mandatory:true。thinking_level 三档;默认档两源打架"
"(官方文档说 HIGH,OpenRouter 说 medium)。"
"**T10 实测未覆盖**: 该渠道本型号上游报错(bad_response_status_code / openai_error),"
"5/5 轮失败,连默认档基线都没取到,两源分歧仍悬着"
),
),
}
)
"""在用模型的推理能力登记(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
@dataclass(frozen=True)
class ThinkingResolution:
"""请求体注入片段 + 本次**实际**生效的档位(设计 §4.1)。
返回 dataclass 而非裸 Mapping,是因为 `nearest` 映射后"请求的档""真正发出
去的档"会分叉(请求 `medium`、模型只有 low/high/max → 实际发 `low`)。遥测必
须记后者: 记请求档会让按档位分组的压测把整行数据挂在一个从未真正发出过的档
,而那种数据错得看不出来
`applied_effort is None` 只出现在 Phase 1(调用方不表态): 库既不注入,也不
去推定模型自己的默认档"没看见"不许说成"发生了"
"""
payload: Mapping[str, Any]
applied_effort: Effort | None
def effective_effort(
*,
request_effort: Effort | None,
source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
**收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`)
与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写
一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"最难查的那种
**一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)
`enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配
从而跳到下一层那正是本次要消灭的静默失效
语法糖排在最末且 `True AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个
硬编码的 `medium`: 那是库替下游做的档位判断, `medium` GLM/kimi/deepseek
档位表里根本不存在(设计 §4.2 声明过的有意变更)
同源同时配 `enable_thinking` `reasoning_effort` 且语义矛盾,已由
`SourceConfig.__post_init__` 在构造期报错,故这里不再判两个字段说同一件事时,
矛盾是配置错误,不是优先级问题
"""
if request_effort is not None:
return request_effort
if source_effort is not None:
return source_effort
if enable_thinking is None:
return None
return Effort.AUTO if enable_thinking else Effort.NONE
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | str | None,
*,
model: str,
fallback: str = "error",
warn_unregistered: bool = True,
) -> ThinkingResolution:
"""档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)**判定顺序即语义,不可调换**:
========== ================================================================
Phase 1 不表态 不注入 `Effort.NONE` 严格区分: 前者是"随模型默认",
后者是"要求不推理"
Phase 2 **该请求档所需的**形态未知 报错(判据见 `_wire_unknown_for`)
无从注入时,模型能力如何都无关紧要,故必须先于 4/5
Phase 3 能力未登记 尽力注入且**不校验档位**没有清单可比对,拿空清单
去拒绝档位就是凭空报错;新模型上线不该被库挡住(设计 §5 R4)
Phase 4 请求 `none` 而模型关不掉 报错并给出 `cheapest_effort`
Phase 5 其余档位打空 报错(或按 `fallback` 映射)
========== ================================================================
**4 必须先于 5**: `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就
退化成"不支持 none,可选 low/high/max"丢掉"这个模型根本关不掉"这个关键
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
issue #20 的成因。
**`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是
"不写 `effort_key`",而不是写进 `effort_key` 的某个取值, Phase 5 放行它
反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`) deepseek-v4
glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量
配置继续可跑那里唯一允许新报错的是"关闭一个官方不可关的模型"
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,
`capability` None(未登记)时无从从别处取得模型名
`fallback="nearest"` Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的
`medium max` GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用
再喊只会刷屏判定结果不受此参数影响
**裸字符串也收**(设计 §4.4 4 条入口): 本函数在 `__all__` ,下游直调时
传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort`
正是这条入口冒出来的时机签名照实写 `Effort | str`下面每一关的判据都是
`is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错
而是**静默判否**: Phase 2 按开启方向取字段Phase 4 整条被绕过,最后在拼错误
文案时才以 `AttributeError` 现形(一个未文档化也不属四分类的异常)
"""
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if effort is not None:
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
# Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档
if effort is None:
return ThinkingResolution({}, None)
wire = profile.thinking
# Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方
if _wire_unknown_for(wire, effort):
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 "
f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 "
f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数"
)
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
if capability is None:
payload = _inject(profile, effort, model=model)
if warn_unregistered:
_warn_unregistered(model, profile, effort, payload)
return ThinkingResolution(payload, effort)
# Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
if effort is Effort.NONE and not capability.can_disable:
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
# Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring)
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
return ThinkingResolution(_inject(profile, applied, model=model), applied)
def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool:
"""Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此只看
`on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none`
被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 )
请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个
`None` 语义互不重叠`off is None` `on_base` 已知是"该 provider 关不掉"
( `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时
指路 `register_provider` 才是对的方向
"""
if effort is not Effort.NONE:
return wire.on_base is None
return wire.off is None and wire.on_base is None
def _settle_tier(
effort: Effort, capability: ThinkingCapability, *, model: str, fallback: str
) -> Effort:
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
`auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key"
(理由见 `resolve_thinking` docstring)
"""
if effort is Effort.AUTO or effort in capability.supported_efforts:
return effort
mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None
if mapped is None:
raise ThinkingUnsupportedError(
_tier_unsupported(model, effort, capability, fallback=fallback)
)
logger.warning(
"模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;"
"本次真正发出去的、以及遥测成功行记的都是后者,但**缓存 key 记的是前者**"
"(CacheMW 在洋葱里比 transport 更外,查缓存时映射尚未发生,拿不到实发档)",
model,
effort.value,
mapped.value,
)
return mapped
def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]:
"""按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。
自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段
(`none` `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提
只对非 `none` 档成立,写进签名反而是句假话
三种 `None` 的语义在此**各自兑现**(ThinkingWire docstring 定义了它们):
`off is None` = provider 关不掉,`effort_key is None` = 它只有开关没有档位
两者都不是"形态未知",故都不指向 `register_provider`指错了排查方向比不指
还糟
"""
wire = profile.thinking
if effort is Effort.NONE:
if wire.off is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 没有关闭形态(模型 {model!r}): "
f"本库知道它如何表达开启,但该 provider 没有可用的关闭参数。"
f"需要不推理请换用支持关闭的 provider 或模型"
)
return wire.off
# 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验)
assert wire.on_base is not None
if effort is Effort.AUTO:
# auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on`
return wire.on_base
if wire.effort_key is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r}),"
f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档,"
f"或用 register_provider 给该 provider 注册 effort_key"
)
return {**wire.on_base, wire.effort_key: effort.value}
def _cannot_disable(model: str, capability: ThinkingCapability) -> str:
"""Phase 4 的文案: 报错必须带一条能立刻照做的替代,否则等于把用户推回起点。
只报"关不掉"而不给出路,下游就会去找 `extra_body` 那条绕过库的路issue #20
的成因正是如此故文案必须含 `cheapest_effort` 的值与 env 键名两样东西
"""
# Phase 4 只在 none 不在清单里时触发,而清单构造期保证非空,故必有一档可推荐
alternative = capability.cheapest_effort
assert alternative is not None
return (
f"模型 {model!r} 无法关闭推理,reasoning_effort='none' 无法满足: "
f"{capability.evidence}。最省的开启档是 {alternative.value!r}——请配 "
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={alternative.value},"
f"或调用时传 reasoning_effort=Effort.{alternative.name};"
f"真正需要不推理请换用支持关闭的模型"
)
def _tier_unsupported(
model: str, effort: Effort, capability: ThinkingCapability, *, fallback: str
) -> str:
"""Phase 5 的文案: 按 `is_tiered` 分叉,纯开关型模型不能被告知"可选档位"
它没有档位对它说"可选档位: none, auto"是把开关说成了强度轴,下游照着找
档位只会一无所获(设计 §3.2 第三个派生量的用途就是这一句话该怎么说)
"""
listed = ", ".join(e.value for e in _ordered(capability.supported_efforts))
head = f"模型 {model!r} 不支持 reasoning_effort={effort.value!r}: {capability.evidence}"
body = (
f"该模型的可选档位: {listed}"
if capability.is_tiered
else f"该模型只有开关、没有强度档位,可用: {listed}"
)
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
return f"{head}{body}{hint}"
def _ordered(efforts: tuple[Effort, ...]) -> list[Effort]:
"""按由弱到强列出档位;`auto` 不在强弱轴上,排在末尾。"""
ordered = [e for e in EFFORT_ORDER if e in efforts]
if Effort.AUTO in efforts:
ordered.append(Effort.AUTO)
return ordered
def _nearest_effort(requested: Effort, capability: ThinkingCapability) -> Effort | None:
"""取距 `requested` 位序最近的**开启档**;等距取弱侧,无开启档时返回 None。
候选**剔除 `none`**: "想得浅一点"映射成"别想了"是方向反转而非省钱,正是
issue #20 那种静默失效的翻版。`none` 的领域归 Phase 4,它在那里已经被处理过,
走不到这里(能关就不会打空,不能关就已经报错)
`auto` 不在强弱轴上(`EFFORT_ORDER` 不含它),故不参与距离计算,只在一个强度
档都没有时兜底它恰好是纯开关型模型唯一能表达""的档
**等距取弱**的理由是钱: 一次静默的 `medium max` GLM-5.3 上是数倍账单,
库不替下游涨价
"""
candidates = [
e for e in EFFORT_ORDER if e is not Effort.NONE and e in capability.supported_efforts
]
if not candidates:
return Effort.AUTO if Effort.AUTO in capability.supported_efforts else None
target = EFFORT_ORDER.index(requested)
# 排序键第二位是位序本身: 距离相同时位序小的(更省的)胜出
return min(
candidates, key=lambda e: (abs(EFFORT_ORDER.index(e) - target), EFFORT_ORDER.index(e))
)
def _warn_unregistered(
model: str, profile: ProviderProfile, effort: Effort, payload: Mapping[str, Any]
) -> None:
logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
model,
profile.name,
dict(payload),
effort.value,
)
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 evidence 曾停在 8-02 整整 23 ),而过期的
表现是静默错觉本函数把它变成可报警事件,代价是一次枚举比较
**判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` "要求关闭"
一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默判据必须写成
`is Effort.NONE` **身份比较**它的取值是非空串 `"none"`,任何靠真值性
的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个
颠倒传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉),
否则文案会说一个从未发出过的档
**不新增**档位高低 vs `reasoning_tokens` 多少的对账(设计 §4.3/§11 1
): 二者没有可判定的函数关系(实测同一档 rt 8~56 之间跳),拿它报警必然
是噪声,而噪声等于没有告警该问题归 §11 的压测,不进库
**只判定不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
而不必去解析日志格式;节流也才能留在握有实例状态的 transport
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
warning(P5 "报错而非放行"只约束限流/熔断)矛盾结果已随 `LLMResponse`
与遥测落地,处置权归下游
"""
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
if effort is None:
return None
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
if effort is Effort.NONE:
if observation is not ThinkingObservation.OBSERVED:
return None
return _off_but_observed(model, capability)
# Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是
# "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档
# 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题
if observation is ThinkingObservation.ABSENT:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数,"
f"上游却明确上报本次未推理(reasoning_tokens=0)"
)
if observation is ThinkingObservation.UNKNOWN:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: "
f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。"
f"若走的是非流式路径,推理内容可能已计费却不回传"
)
return None
def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str:
"""关闭请求未被满足的两种说法;登记与否决定该说哪一句。
两者必须分开: `resolve_thinking` 对未登记模型的告警是**事前猜测**,这里是
**事后实证**对未登记模型说"能力表声称可关闭"是错的它根本没登记
"""
if capability is None:
return (
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
f"请实测后用 register_capability 登记其真实能力"
)
return (
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
f"能力表可能已过期——请复测后用 register_capability 更新登记"
)
+115 -18
View File
@@ -11,9 +11,11 @@ from __future__ import annotations
import json
import re
import time
from dataclasses import replace
from typing import TYPE_CHECKING, Any
import httpx
from loguru import logger
from polygateway.errors import (
PolyGatewayError,
@@ -22,17 +24,24 @@ from polygateway.errors import (
SourceDeadError,
TransientError,
)
from polygateway.providers import (
ProviderProfile,
from polygateway.providers import ProviderProfile, get_provider
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.thinking import (
ThinkingCapability,
ThinkingUnsupportedError,
effective_effort,
get_capability,
get_provider,
observe_thinking,
reconcile_thinking,
resolve_thinking,
)
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
from polygateway.types import (
Effort,
EmbeddingTransportResult,
SourceConfig,
TransportResult,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping
@@ -320,6 +329,12 @@ class OpenAICompatTransport:
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
self._warned_models: set[str] = set()
# 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记
# 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -338,7 +353,14 @@ class OpenAICompatTransport:
profile: ProviderProfile,
stream: bool,
overlay: dict[str, Any],
) -> dict[str, Any]:
reasoning_effort: Effort | None,
) -> tuple[dict[str, Any], Effort | None]:
"""组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。
返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,
除本函数外没有第二处知道映射结果不交出去,遥测就只能事后再算一遍,
算出来必是请求档
"""
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
if stream:
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
@@ -347,20 +369,28 @@ class OpenAICompatTransport:
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(
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
resolution = resolve_thinking(
profile,
capability,
source.enable_thinking,
effective_effort(
request_effort=reasoning_effort,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
model=source.model,
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
fallback=source.effort_fallback,
warn_unregistered=first_time,
)
)
payload.update(resolution.payload)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换
payload.update(source.extra_body)
payload.update(overlay)
return payload
return payload, resolution.applied_effort
async def complete(
self,
@@ -370,12 +400,22 @@ class OpenAICompatTransport:
stream: bool,
overlay: dict[str, Any],
call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
`reasoning_effort` **请求级**档位(`None` = 不表态);它与源级配置的优先级
`_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到
"""
profile = get_provider(source.provider, registry=self._registry)
try:
payload = self._build_payload(
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
payload, applied_effort = self._build_payload(
messages=messages,
source=source,
profile=profile,
stream=stream,
overlay=overlay,
reasoning_effort=reasoning_effort,
)
except ThinkingUnsupportedError as exc:
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
@@ -390,8 +430,9 @@ class OpenAICompatTransport:
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
try:
if stream:
return await self._complete_stream(client, url, payload, source, profile)
return await self._complete_once(client, url, payload, source, profile)
result = await self._complete_stream(client, url, payload, source, profile)
else:
result = await self._complete_once(client, url, payload, source, profile)
except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
except httpx.TimeoutException as exc:
@@ -399,6 +440,50 @@ class OpenAICompatTransport:
except httpx.TransportError as exc:
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
# 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条
# 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是
# 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条
result = replace(result, applied_effort=applied_effort)
# 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里
self._warn_on_thinking_mismatch(source, result)
return result
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
"""声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。
三段缺一不可**档位**: 同一模型的 low max 是两个独立的矛盾,共用一个
键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过
那个字段,于是同一模型的所有档共用一个键)**源名**: 多源多账号是本库的
核心场景,同一 model N 个源是常态,而每个源背后是独立的账号/网关,一个
源的行为不代表另一个漏掉源名,5 个源里第一个出问题的喊完一次,其余四个
永久静音逐次调用刷屏会把告警变成噪声,噪声等于没有告警
档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest`
映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"拿一个从未发出
过的档去对账,文案与键都指向了一次不存在的请求被映射到同一档的两个请求
因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费
"""
key = (source.name, source.model, result.applied_effort)
if key in self._warned_mismatches:
return
message = reconcile_thinking(
effort=result.applied_effort,
observation=result.thinking_observation,
capability=get_capability(source.model, table=self._capabilities),
model=source.model,
)
if message is None:
return
self._warned_mismatches.add(key)
# 源名拼在调用点而不是加进 `reconcile_thinking` 的签名: 那是纯判定函数,
# 输入只该含判定依据(声明/观测/能力/模型),源名是**定位信息**,进不了判据。
# 单参数传入 loguru: 文案里带 `thinking:{type:disabled}` 这类字面花括号
# (能力表 evidence),将来有人给这行加个格式化参数就会炸在成功调用的返回
# 路径上(与 telemetry/sqlite.py 的缺列告警同一先例)
logger.warning("{} —— {}", source.name, message)
async def embed(
self, *, texts: list[str], source: SourceConfig, call_id: str
@@ -460,6 +545,7 @@ class OpenAICompatTransport:
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -471,7 +557,12 @@ 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")),
reasoning_tokens=reasoning_tokens,
# 两条组装路径必须同口径裁定: 只在一条路径上给结论,下游就得靠
# "这次是不是流式"去猜可观测性,那正是 issue #16/#17 的根因形态
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
def _check_done(
@@ -545,6 +636,7 @@ class OpenAICompatTransport:
)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -556,7 +648,12 @@ 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")),
reasoning_tokens=reasoning_tokens,
# 本路径的裁定多半落 UNKNOWN(M3 实测: 推理已计费却正文与 details 双
# 缺)。如实标记"观测不到",好过让下游误读成"没推理"
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
async def aclose(self) -> None:
+225 -1
View File
@@ -10,6 +10,7 @@ import math
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType
from typing import Any
@@ -17,6 +18,9 @@ from loguru import logger
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
{
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
@@ -166,6 +170,110 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
class Effort(StrEnum):
"""推理强度档位的封闭词汇(设计 §3.1)。
取值直接写进请求体(`reasoning_effort` 等键),**改名即改变发出去的字节**,
且会进缓存 key 与遥测落库,历史数据会断层
八档而非六档: `none`(不推理) `auto`(推理,档位由模型自定)必须同时存在
`auto` 不可省newapi 26 个可调用模型里有 9 个是**纯开关型**(qwen 五个
MiniMax-M3glm-5/5.1/4.6v),它们能开推理却没有强度档可填;没有 `auto` 就只
能拿某个强度档冒充"",而那正是本次要修的病根( `thinking_on` 硬编码
`medium`, `medium` GLM/kimi/deepseek 的档位表里根本不存在)
词汇取四家参考实现共同收敛的一套(cherry-studio canonical selection
OpenRouter `supported_efforts`LiteLLM `reasoning_effort_levels`
new-api `relayconvert/reasoning`),不自创
**枚举定义在最内层而非决策层**: 它是 `SourceConfig`/`ChatRequest`/
`LLMResponse` 的字段类型,放进 `thinking.py` 会让 `types.py` 反向 import
决策模块(P7 依赖铁律), `ThinkingObservation` 同一理由
"""
NONE = "none"
AUTO = "auto"
MINIMAL = "minimal"
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
XHIGH = "xhigh"
MAX = "max"
EFFORT_ORDER: tuple[Effort, ...] = (
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
)
"""由弱到强的强度序;`AUTO` **不在其中**——它是"由模型自定",在强弱轴上没有位置。
供能力表求"最省的开启档" `nearest` 映射取最近档公开( `_` 前缀)是因为
`thinking.py` 要跨模块消费它,跨模块引用私有名是坏味道
"""
def coerce_effort(raw: Any, *, origin: str) -> Effort:
"""把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。
存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较
(枚举成员唯一,`is` `==` 更能表达"就是这一档"), `Effort` `StrEnum`
下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE`
恒为假不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成
矛盾),随后拼错误文案时再 `.value` `AttributeError`,连承诺的 `ValueError`
都拿不到(2026-09-05 独立验证实测)
故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 /
构造函数全量注入,CLAUDE.md §4.5)口径劈成两半, `.env` 那条早已是"解析即
归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的
行尾空格与大写写法是常态,而档位取值本身没有大小写语义
`origin` 指回具体的配置项或调用点: 档位在源级请求级两处都能配,只说
"非法档位"要人自己去找是哪一处填错了传空串表示调用方自己会补上下文
(`config._cast` `配置 X 解析失败` 已经说了是哪个 env )
"""
if isinstance(raw, Effort):
return raw
prefix = f"{origin}: " if origin else ""
listed = ", ".join(e.value for e in Effort)
if isinstance(raw, str):
try:
return Effort(raw.strip().lower())
except ValueError:
# 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一
# 件事的英文复述,链上去反而把可操作的那句挤到后面
raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None
raise ValueError(
f"{prefix}推理档位必须是 Effort 或其字面量字符串,"
f"收到 {type(raw).__name__}: {raw!r};允许: {listed}"
)
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态**不可折叠为布尔**: `UNKNOWN` "本次无任何信号,判不出来",
`ABSENT`("上游明确上报了未推理")语义不同把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义库据此静默宣称"没推理",而实际
可能推理了且已计费(MiniMax-M3 非流式实测: completion 53 vs 关闭档 3,
推理正文与 usage 明细双双不回传)
裁定由 `thinking.observe_thinking` ,本类只是取值域**枚举定义在最内层
而非决策层**: 它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让
`types.py` 反向 import 决策模块(P7 依赖铁律)
取值进遥测落库,改名即造成历史数据断层
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -202,7 +310,31 @@ class LLMResponse:
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
6:4 双峰)实测三家供应商在未推理时都是整个 details 缺失无人上报 `0`,
故下游判据须为 `in (None, 0)`, `== 0` 的条件永远不成立"""
故下游判据须为 `in (None, 0)`, `== 0` 的条件永远不成立
**该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报
`completion_tokens_details`(MiniMax 这一路实测已停),此时 `None` 只意味着
没上报而非没推理同一次调用里库拿得到 185 字符推理正文有没有
推理一律改读 `thinking_observation`,上面那段只用于解读本版之前的历史数据"""
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的三态裁定(issue #16/#17)。
`UNKNOWN` = **本次无任何信号,判不出来**,**不是**"没推理"把两者折叠
`reasoning_tokens=None` 制造的老歧义典型来源: 非流式路径下部分模型
推理已计费却既不回传正文也不回传 `completion_tokens_details`(MiniMax-M3
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)"""
applied_effort: Effort | None = None
"""本次调用**真正发出去**的推理档位(issue #20);`None` = 调用方未表态。
`ChatRequest.reasoning_effort`(请求档)可能分叉: 源上配了
`EFFORT_FALLBACK=nearest` ,请求 `medium` 而模型只有 low/high/max,实际发
出的是 `low`遥测按本字段分组,记请求档会把整行挂在一个从未发出过的档下
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档"没看见"不许说成
"发生了"( `thinking_observation` `UNKNOWN` 一脉)"""
@dataclass(frozen=True)
@@ -239,6 +371,17 @@ class ChatRequest:
再进一次既重复又会让存量缓存全量冷启动; `meta` 承载的是审计维度而非
语义维度, messages namespace 下换个 batch_id 不应导致 miss"""
# —— 请求级推理档位(issue #20;追加在末尾,不扰动既有字段的位置构造)——
reasoning_effort: Effort | None = None
"""本次调用要求的推理档位,压过源级默认(设计 §4.2 的最高优先级层)。
`None` **不表态**(随源级配置), `Effort.NONE`("要求不推理")严格区分:
把前者读成后者会让一次没写档位的调用悄悄关掉源上配好的推理
独立成字段而非塞进 `overlay`: `overlay` 是采样参数的直通层,库不解释其内容,
而档位要经能力表校验要进缓存 key要落遥测混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
@dataclass(frozen=True)
class Usage:
@@ -299,6 +442,18 @@ class TransportResult:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding )沉默
,不该替上游做出"没推理"这个它从未做过的声明"""
applied_effort: Effort | None = None
"""本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。
只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求
`medium` 实发 `low`),中间件事后再算一遍必然算成请求档默认 `None`
"未表态/不注入推理参数"(OCRembedding transport 沉默即此)"""
@dataclass(frozen=True)
@@ -307,6 +462,10 @@ class SourceConfig:
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)
True=注入开启参数False=注入关闭参数(统一 VT CHS 相反的现状)
2026-09-04 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
(`True``AUTO``False``NONE`),保留不删是因为它已被三项目消费
(迁移兼容约束,ARCH §5.1)两个字段说的是同一件事,故矛盾即报错
"""
name: str
@@ -331,10 +490,29 @@ class SourceConfig:
(加任何 mapping 字段的固有代价, dict 亦然),库内无以源作 key 的写法;
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`"""
reasoning_effort: Effort | None = None
"""本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。
裸字符串(`"low"``" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`,
非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内
`is Effort.NONE` 身份比较依赖这条不变式
**追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位
(本模块头部 docstring 的字段保序约定)"""
effort_fallback: str = "error"
"""请求档打空时的处置: `error`(默认,报错)或 `nearest`(映射到最近的档)。
默认报错的理由是钱: 一次静默的 `medium max` GLM-5.3 上是数倍账单
(P5严禁默认值掩盖错误)值域在此把关而非交给 `resolve_thinking`
后者对未知值是 fail-closed( `error` 处理),不会替配置兜错,漏判的结果
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效"""
def __post_init__(self) -> None:
self._validate_identity()
self._validate_gates()
self._validate_watchdog()
self._validate_thinking()
self._freeze_extra_body()
def effective_est_tokens(self) -> int:
@@ -372,6 +550,52 @@ class SourceConfig:
):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_thinking(self) -> None:
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共
入口,`reasoning_effort="none"` 这种裸字符串写法( JSON/配置读出来的
常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`同一
理由也适用于下游读侧归一化后库内一律是 `Effort`,`is` 比较才安全
矛盾**报错而非后者赢**: `enable_thinking` `reasoning_effort` 表达的是
同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单
猜成开启就是白花钱,猜成关闭就是拿到一个没推理过的答案
判据是二者是否都在说关闭: `enable_thinking is False`
`reasoning_effort is NONE` 必须同真同假`True` + 某个开启档( `low`)
不算矛盾,那只是把同一件事说了两遍,且后者更精确
"""
if self.reasoning_effort is not None:
# frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body)
object.__setattr__(
self,
"reasoning_effort",
coerce_effort(
self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort"
),
)
if isinstance(self.effort_fallback, str):
# 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是
# 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的
# 同一处(而不是 env 解析处),三条配置路一并覆盖
object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower())
if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN:
raise ValueError(
f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 "
f"{self.effort_fallback!r};允许: {sorted(_EFFORT_FALLBACK_DOMAIN)}"
)
if self.enable_thinking is None or self.reasoning_effort is None:
return
if (self.enable_thinking is False) != (self.reasoning_effort is Effort.NONE):
raise ValueError(
f"{self.name!r} 的 enable_thinking={self.enable_thinking}"
f"reasoning_effort={self.reasoning_effort.value!r} 相互矛盾: "
f"enable_thinking 已是 reasoning_effort 的语法糖"
f"(True={Effort.AUTO.value}、False={Effort.NONE.value})。"
f"请只保留其中一个,或让两者语义一致"
)
def _freeze_extra_body(self) -> None:
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
validated = validate_request_overlay(
+676 -47
View File
@@ -1,51 +1,63 @@
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
"""真实 API 验证推理开关与推理可观测性(issue #5 + #6;判据于 #16/#17 重建)。
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
验证不了"这个参数在这个模型上到底关没关掉推理"
条判据纪律(来自 findings §4c 的实测教训):
条判据纪律( 12 条来自 findings §4c, 1 条的推翻与第 3 条来自
`findings/2026-08-25-thinking-observability-regression.md`):
1. **判别量只能 `reasoning_tokens`, `completion_tokens`** 两档的输出
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
开启档最低 13 token(medium 档想得少的那几轮),长度阈值判两边都会误判
`reasoning_tokens` 在同一批 30 轮里干净分开关闭 15/15 None,
开启 15/15 大于 0
2. **另配一个不含魔数的确定性锚点**( L2b): 同一模型上,关闭档的
1. **判别量是库裁定的三态 `thinking_observation`,不是 `reasoning_tokens`
也不是 `completion_tokens`** 长度判据早已排除: 两档的输出长度分布**
重叠的**(实测关闭档最高 46 token开启档最低 13 token),按阈值判两边都会
误判 `reasoning_tokens` 这个曾经"干净分开"的判据也已失效MiniMax
这一路上游不再返回 `usage.completion_tokens_details`,该字段恒 `None`;同一
次调用里库明明拿得到 185 字符推理正文,单看 token 计数却把"推理正常"读成
"没推理"(2026-08-25 findings §3.4/结论③,四条用例因此假红)三态裁定同时
看正文与计数: **正文是事实本身,token 计数只是对事实的转述**
2. **另配一个不含魔数的确定性锚点**( L2bL5): 同一模型上,关闭档的
`prompt_tokens` 严格小于开启档供应商在开启时注入了推理指令,输入侧
token 数随之变大这是相对比较,不硬编码任何具体数值
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足** 中转在上游不返回
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
开启方向因此可能偶尔观测不到;关闭方向不受影响
token 数随之变大这是相对比较,不硬编码任何具体数值;且它不依赖上游是否
回传推理正文,所以在"观测不到推理"的非流式路径上依然作数
3. **`UNKNOWN` 不等于"没推理",不能拿它判红** 关闭方向要求每轮"未观测到
推理"(`UNKNOWN` 计入满足——它没有证伪力),其证伪力来自: 模型若偷偷推理了,
可观测路径会翻成 `OBSERVED`开启方向只要求多数轮 `OBSERVED`;M3 非流式
路径整片观测不到,该档由 L5 用另一套断言覆盖
源不可用一律 `skip` 并在报告中记为未覆盖,**绝不静默计入通过**
"""
import asyncio
import dataclasses
import json
import os
from collections import Counter
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from types import MappingProxyType
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings
from polygateway import GatewayClient, GatewaySettings, ThinkingObservation
from polygateway.errors import (
AllSourcesExhausted,
GatewayUnavailableError,
RequestRejectedError,
SourceDeadError,
TransientError,
)
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability, get_capability
from polygateway.types import EFFORT_ORDER, Effort
_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` 显式真跑并
# 存档报告。"不自动门控"不等于"可跳过"。
# slow: 本组 92 次真实调用、约 4半(2026-08-26 判据换三态后实测;此前记的
# "137 次、约 7 分钟"已被证伪,别照旧值估 CI 预算),且判据是统计性的——网络抖动
# 会让它偶发失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci`
# 会把测试变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow`
# 显式真跑并存档报告。"不自动门控"不等于"可跳过"。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
@@ -60,9 +72,6 @@ _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
_ON_MIN_COMPLETION = 100
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
_ROWS: list[dict] = []
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
@@ -73,7 +82,28 @@ _MODEL_PROVIDER = {
"MiniMax-M2.7": "minimax",
"MiniMax-M2.5": "minimax",
"qwen3.7-plus": "qwen",
"qwen3.7-max": "qwen",
"qwen3.6-plus": "qwen",
"qwen3.5-flash": "qwen",
"qwen-plus-latest": "qwen",
"deepseek-v4-pro": "deepseek",
"deepseek-v4-flash": "deepseek",
"deepseek-v4-flash-vision-exp": "deepseek",
"glm-5.3": "zhipu",
"glm-5.3-flash": "zhipu",
"glm-5.2": "zhipu",
"glm-5.1": "zhipu",
"glm-5": "zhipu",
"glm-4.6v": "zhipu",
"kimi-k3": "moonshot",
"kimi-for-coding": "moonshot",
"gpt-5.4": "openai",
"gpt-5.5": "openai",
"claude-opus-5": "anthropic",
"claude-sonnet-5": "anthropic",
"claude-haiku-5": "anthropic",
"gemini-3.1-pro": "google",
"gemini-3-flash": "google",
}
@@ -107,6 +137,10 @@ async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
# 结论与证据一起入报告: 只记 observation 会让"为什么这么判"
# 不可复核,而 thinking_chars 正是本次改判的直接证据
"thinking_observation": resp.thinking_observation,
"thinking_chars": len(resp.thinking),
"content": resp.content[:60],
}
)
@@ -128,26 +162,32 @@ def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只看 reasoning_tokens
"""关闭方向: 只要没观测到推理即算满足
`UNKNOWN` 计入满足是有意的: 它没有证伪力(本次无任何信号,判不出来),拿它
判红等于每次关闭调用都喊一遍本判据真正的证伪力在于模型若偷偷推理了,
可观测路径会把裁定翻成 `OBSERVED`
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理加长度门只会
把这种正常波动误判成"没关掉"
"""
return obs["reasoning_tokens"] in (None, 0)
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据
"""开启方向: 观测到推理即为真
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据
判据从 `reasoning_tokens` 换成库的三态裁定,因为 MiniMax 这一路已不再上报
`completion_tokens_details`(2026-08-25 findings 结论②),该字段恒 `None`;
而库在同一次调用里拿得到 185 字符推理正文(findings §3.4)旧判据看不见
,L2/L3b/L4/L5 四条因此假红
也不能退回 completion_tokens 当判据: medium 档的推理量方差极大(实测 15
7-170 token),两档分布还与关闭档重叠,按长度阈值判会把"推理了但想得少"
误判成没推理
"""
reasoning = obs["reasoning_tokens"]
if reasoning is not None:
return reasoning > 0
return obs["completion_tokens"] > _ON_MIN_COMPLETION
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
@@ -163,14 +203,18 @@ def _write_report():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
lines = [
"# 推理开关与 reasoning_tokens 真实 API 验证",
"# 推理开关与推理可观测性真实 API 验证",
"",
f"- 时间: {ts}",
f"- 每档轮数: {_ROUNDS}",
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"- 判别量: 库裁定的三态 `thinking_observation`(OBSERVED/ABSENT/UNKNOWN),"
"由推理正文与 reasoning_tokens 共同裁定 —— 正文是事实,token 计数只是转述",
"- 关闭判据: **轮** observation != OBSERVED(UNKNOWN 计入满足,它没有证伪力);"
"刻意不设输出长度上限(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
"- 开启判据: **多数轮** observation == OBSERVED",
"- 确定性锚点(L2b、L5): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"- L5(非流式): M3 该路径推理已计费却不回传正文,故不断言「观测到推理」,"
"改断锚点可分 + 开启档不被误判为 ABSENT",
"",
"## 矩阵结论",
"",
@@ -206,7 +250,7 @@ class TestMiniMaxM3:
"L1",
"enable_thinking=False(流式)",
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理",
f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs,
)
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
@@ -256,7 +300,7 @@ class TestMiniMaxM3:
"L3",
"enable_thinking=None(不干预,基线)",
"PASS" if len(quiet) == len(obs) else "FAIL",
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
f"{len(quiet)}/{len(obs)} 轮未观测到推理(M3 默认档本就不推理)",
obs,
)
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
@@ -271,6 +315,12 @@ class TestMiniMaxM3:
判别方法: 发一个**非法值**若未知值会被静默丢弃,它的表现应与"不注入"
一致(不推理);实测它反而开启了推理,说明网关认这个键只是不认这个值
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值
**该手法不可移植,只对"认这个键但不校验值" provider 成立**: minimax
非法 `reasoning_effort` 返回 200 且照常推理(2026-08-25 findings §5:
prompt 207,介于基线 194 medium 216 之间,走了第三条模板路径); qwen
对同样的值直接返回 **HTTP 400**把本用例套到 qwen 那类会校验值的 provider
,拿到的会是异常而非"不推理",是假红
"""
rounds = max(3, _ROUNDS // 3)
bogus = await _run_rounds(
@@ -318,23 +368,50 @@ class TestMiniMaxM3:
)
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
async def test_l5_non_stream_path_matches_stream(self):
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self):
"""非流式快路径: 参数确实到达了模型,而推理信号被如实标成"观测不到"
**本用例不能断言"非流式开启档观测到推理"那永远不成立**: M3 在非流式
路径下推理段确实产生并计费(2026-08-25 findings §3.4: 开启档 completion 53
vs 关闭档 3), `message` 里没有 `reasoning_content``usage` 里也没有
`completion_tokens_details`,推理内容整体不回传**这是上游行为,库修不了;
库能做也必须做的是让它可见**下游在为看不见的东西付费,不该由库替它
沉默
故改断两件在非流式下真实成立的事:
其一 `prompt_tokens` 锚点仍把两档分开(判据形态照抄 L2b,证明注入到达了模型,
排除"非流式路径把参数弄丢了"这一伪解释);
其二开启档的裁定**不是 `ABSENT`**`ABSENT` 的语义是"上游明确上报未推理",
而实情是"判不出来"(`UNKNOWN`),库若把后者伪装成前者,正是 issue #16/#17 里
那个静默错觉这里断 `!= ABSENT` 而非 `== UNKNOWN`,是为了留出上游哪天开始
回传正文的余地: 那时裁定会翻成 `OBSERVED`,是好事,不该让它把测试判红
"""
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)
off_max = max(o["prompt_tokens"] for o in off)
on_min = min(o["prompt_tokens"] for o in on)
not_absent = [o for o in on if o["thinking_observation"] != ThinkingObservation.ABSENT]
on_states = Counter(str(o["thinking_observation"]) for o in on)
ok = len(offs) == len(off) and off_max < on_min and len(not_absent) == len(on)
_record(
"L5",
"非流式路径重跑 L1/L2",
"非流式: prompt 锚点可分 + 开启档如实标 UNKNOWN 而非 ABSENT",
"PASS" if ok else "FAIL",
f"关闭 {len(offs)}/{len(off)},开启 {len(ons)}/{len(on)}",
f"关闭 {len(offs)}/{len(off)}未观测到推理;"
f"关闭档 prompt 最大 {off_max} < 开启档最小 {on_min};"
f"开启档裁定分布 {dict(on_states)}",
off + on,
)
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
assert off_max < on_min, (
f"非流式两档 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): "
f"开启参数可能没到达模型"
)
assert len(not_absent) == len(on), (
f"非流式开启档被裁成 ABSENT(声称上游明确上报未推理),而实情是观测不到: {on}"
)
class TestOtherProviders:
@@ -357,11 +434,36 @@ class TestOtherProviders:
matrix,
desc,
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理",
f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs,
)
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
async def test_qwen_enabled_is_observed(self):
"""设计 §14 验收: qwen 开启档必须裁定为 `OBSERVED`,不是 `UNKNOWN`。
本条是三态裁定的**跨供应商对照组**: MiniMax 这一路两个信号都可能缺失
(非流式档整片 `UNKNOWN`),若只按它调判据,很容易把"观测不到"当成常态;
qwen 在同一网关同一 key 上照常返回推理信号(findings 2026-08-25 §2),
故这里能且必须要求正面结论它一旦掉成 `UNKNOWN`,说明的是库的组装路径
丢了信号,而不是上游行为变了
"""
matrix, provider, model = "L6b", "qwen", "qwen3.7-plus"
desc = f"{provider} enable_thinking=True"
try:
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=True)
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
_skip_if_unreachable(exc, matrix, desc)
ons = [o for o in obs if _reasoning_on(o)]
_record(
matrix,
desc,
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
f"{len(ons)}/{len(obs)} 轮观测到推理(OBSERVED)",
obs,
)
assert len(ons) * 2 > len(obs), f"{provider} 开启方向要求多数轮 OBSERVED: {obs}"
class TestCapabilityDrift:
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
@@ -395,7 +497,7 @@ class TestCapabilityDrift:
"L8",
desc,
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
f"实测未观测到推理 {dict(verdict)}(True=满足);声明 can_disable=True 要求每轮满足",
obs,
)
assert len(offs) == len(obs), (
@@ -438,7 +540,534 @@ class TestAssemblyGuardAgainstRealConfig:
stream=True,
overlay={},
call_id="e2e-guard",
reasoning_effort=None,
)
finally:
await client.aclose()
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
# ══════════════════════════════════════════════════════════════════════════════
# T10: 逐模型档位实测(方法论沿用 issue #20)
#
# 本节与上面的 L1-L9 分工不同: 上面验的是**库的行为**(注入到没到、观测准不准),
# 这里验的是**能力表的内容**(`DEFAULT_CAPABILITIES` 里那 20 多条声明是不是真的)。
# 二者判据可以共用,数据源却必须分开——能力表实测要**绕过能力表**才有意义,
# 否则拿待验证的声明去挡请求,等于用结论证明前提。
#
# 判据(三条,均沿用已有纪律):
# ① 关闭方向: 每轮 `thinking_observation != OBSERVED` 才算真关掉;任一轮
# OBSERVED 即证伪(推理正文是事实本身,不需要多数票)。
# ② **短提示词的"关掉了"必须经长上下文复核**: issue #20 实测 GLM 系在短提示词
# 下 reasoning_tokens≈1.2 像是关了,5552 token 长上下文下跳到 0/54/167 即露馅。
# 短提示词下推理量本就趋近于 0,分不出"关了"与"没什么可想的"。
# ③ 开启方向: 多数轮 OBSERVED(单轮抖动不判红,与 L2 同口径)。
# ④ 关闭结论**不许只靠 `UNKNOWN`**: 上游整片不回传推理信号时(kimi、MiniMax 两路
# 都是),"没看见"不是"没发生"。此时补一个不含魔数的锚点——关闭档的
# `completion_tokens` 必须严格小于 `max` 档,否则结论记为「判不出来」。
# ══════════════════════════════════════════════════════════════════════════════
_TIER_OUT_DIR = Path("tests/outputs/thinking")
_TIER_ROUNDS = int(os.environ.get("PGW_E2E_TIER_ROUNDS", "5"))
_TIER_LONG_ROUNDS = int(os.environ.get("PGW_E2E_TIER_LONG_ROUNDS", "3"))
# 共用生产网关,宁慢勿冲(人类 2026-09-05 指令): 默认 3,可下调不建议上调
_TIER_CONCURRENCY = int(os.environ.get("PGW_E2E_TIER_CONCURRENCY", "3"))
# 固定短提示词: 答案本身约 4 token,推理 token 的信噪比高(issue #20 同款)
_TIER_PROMPT = "23 乘以 47 等于多少?只回答一个数字,不要解释。"
# 长上下文对照组(判据②)。填充文本与题目无关且不含任何业务领域词汇(零业务假设
# 铁律),只为把输入撑到数千 token;题目放在最后,避免被当成"读完就忘"的前缀
_TIER_LONG_PROMPT = (
"\n".join(
f"{i:04d}. 这是一段与题目无关的填充文字,仅用于把上下文撑到数千 token,"
"以复核短提示词下得到的关闭结论在长上下文下是否依然成立。"
for i in range(120)
)
+ "\n\n"
+ _TIER_PROMPT
)
_ALL_EFFORTS: tuple[Effort, ...] = (*EFFORT_ORDER, Effort.AUTO)
_PROBE_ROWS: list[dict] = []
def _tier_settings(model: str) -> GatewaySettings:
"""探测用配置: 生产口径的超时,但**重试预算压到 1 次**。
压重试是因为探测里"这一轮失败"本身就是数据(逐轮进报告),库替它重试只会
"渠道当下不可用"变成三倍等待2026-09-05 实测 claude 7 天限额用尽时
每轮 429,三次重试让单个模型阻塞三分钟以上,26 个模型跑不完
**单次请求的超时不动**(仍是 .env 的生产值 300s): §4.6 那条"测试超时不得紧于
生产配置"防的是把慢而正常的模型误判成不可用,那个风险在这里照旧存在。重试次数
与背压窗口不属于同一类它们决定"失败之后还等多久",而不是"多慢算失败";
一个真在出字的模型永远碰不到这两者
"""
base = GatewaySettings.from_env(
"LLM",
env={
**_ENV,
"PGW_CACHE_BACKEND": "none",
"LLM_MAX_RETRIES": "1",
# 探测是**单源**的,没有别的源可换。生产值 1200s 的 stall window 在这里
# 只会把"这个模型当下不可用"拖成 20 分钟一轮: 2026-09-05 实测 claude 系
# 7 天限额用尽返回 429 且不带 Retry-After,库据此判"无可运行源"并按背压
# 语义等到窗口耗尽(实测把窗口调到 45s 即在 46.7s 报 stalled)。多源生产
# 场景下这段等待是有意义的(等别的源恢复),探测场景下等不到任何东西
"LLM__BACKPRESSURE__STALL_WINDOW_S": "60",
},
)
source = dataclasses.replace(
base.sources[0],
provider=_MODEL_PROVIDER[model],
model=model,
enable_thinking=None,
reasoning_effort=None,
)
return dataclasses.replace(base, sources=(source,))
def _probe_capabilities(model: str) -> dict[str, ThinkingCapability]:
"""临时全档能力表: **实测的对象正是能力表本身**,不能拿它当前提去挡请求。
不传 `capabilities={}`("未登记")的理由是噪声: 那条路会走 Phase 3,每轮都
warning 一句"能力未登记",几百轮下来把真正的告警淹没全档表让五关全部放行,
请求原样发出去,由上游而不是由库来回答"这一档到底行不行"
"""
return {model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 实测临时表(不进 DEFAULT)")}
async def _probe_effort(
model: str, effort: Effort, *, rounds: int, prompt: str, prompt_kind: str
) -> list[dict]:
"""对一个 (模型, 档位) 打 N 轮真实请求,逐轮记录;失败轮记 `error` 而不冒泡。
失败不冒泡是本函数与 `_run_rounds` 的唯一区别: 这里"上游拒绝这一档"本身就是
**实测结论**(HTTP 400 = 该档不被接受),把它抛出去会让数据采集半途而废
只吞四分类与 `AllSourcesExhausted`库自身的 `ValueError` 等仍然冒泡,那是
bug 不是数据
"""
client = GatewayClient.from_settings(
_tier_settings(model), capabilities=_probe_capabilities(model)
)
semaphore = asyncio.Semaphore(_TIER_CONCURRENCY)
async def _one(index: int) -> dict:
base = {"round": index + 1, "effort": effort.value, "prompt_kind": prompt_kind}
async with semaphore:
try:
resp = await client.chat(
[{"role": "user", "content": prompt}],
stream=True,
reasoning_effort=effort,
cache_salt=f"tier-probe-{model}-{effort.value}-{prompt_kind}-{index}",
)
except (
RequestRejectedError,
GatewayUnavailableError,
SourceDeadError,
TransientError,
) as exc:
# 捕 `GatewayUnavailableError` 而不是只捕 `AllSourcesExhausted`:
# 某个模型在网关上不通时,连续失败会把熔断门打开,后续轮次抛的是
# `CircuitOpenError`(同一父类的兄弟)。只捕子类会让"源不可用"这
# 件事在第 N 轮换个类型冒出去,把数据采集打断成一次红测
return {**base, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
return {
**base,
"error": None,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
"thinking_chars": len(resp.thinking),
"thinking_observation": resp.thinking_observation,
"applied_effort": resp.applied_effort,
# 核对模型身份: issue #20 记录本渠道对 glm-5.2 的请求 6/6 回报
# model=glm-5.3。凡结论依赖模型身份的,对不上即数据不可信
"model_reported": resp.model_reported,
"content": resp.content[:40],
}
try:
return list(await asyncio.gather(*(_one(i) for i in range(rounds))))
finally:
await client.aclose()
def _probe_ok(obs: dict) -> bool:
return obs["error"] is None
def _probe_quiet(obs: dict) -> bool:
"""成功且未观测到推理(判据①的满足条件);失败轮不算"安静",它没有观测。"""
return _probe_ok(obs) and obs["thinking_observation"] != ThinkingObservation.OBSERVED
def _probe_observed(obs: dict) -> bool:
return _probe_ok(obs) and obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _rt_summary(observations: list[dict]) -> str:
"""报告里的一行摘要: rt 观测值序列 + 裁定分布 + 身份核对,三样缺一不可复核。"""
ok = [o for o in observations if _probe_ok(o)]
if not ok:
return f"全部 {len(observations)} 轮失败: {observations[0]['error']}"
rts = [o["reasoning_tokens"] for o in ok]
verdicts = Counter(str(o["thinking_observation"]) for o in ok)
reported = sorted({str(o["model_reported"]) for o in ok})
failed = len(observations) - len(ok)
tail = f";{failed} 轮失败" if failed else ""
return (
f"rt={rts};裁定 {dict(verdicts)};thinking_chars="
f"{[o['thinking_chars'] for o in ok]};model_reported={reported}{tail}"
)
# 已知的合法别名: 供应商回报的名字与配置里的别名本就可以不同(月之暗面回
# `k3`、Google 回 `-preview` 后缀)。**显式登记而不是按前缀猜**——猜的话
# `glm-5.2 → glm-5.3` 这种真·串台也会被当成"同族别名"放过,而那正是本表要抓的
_MODEL_REPORTED_ALIASES: Mapping[str, frozenset[str]] = MappingProxyType(
{
"kimi-k3": frozenset({"k3"}),
"kimi-for-coding": frozenset({"k3"}),
"gemini-3-flash": frozenset({"gemini-3-flash-preview"}),
"gemini-3.1-pro": frozenset({"gemini-3.1-pro-preview"}),
}
)
def _identity_mismatch(model: str, observations: list[dict]) -> list[str]:
"""响应体里的 `model` 与请求的模型对不上 → 本次数据说的不是这个模型。
issue #20 就栽在这里: 该渠道对 `glm-5.2` 的请求 6/6 回报 `model=glm-5.3`,
照单全收的话,能力表里 glm-5.2 那一行记的其实是 glm-5.3 的行为凡结论依赖
模型身份的,对不上就必须当场作废,而不是打个折扣继续用
`None`(上游未上报)不算不符: 那是"没说",不是"说了别的"
"""
allowed = {model, *_MODEL_REPORTED_ALIASES.get(model, frozenset())}
return sorted(
{
o["model_reported"]
for o in observations
if _probe_ok(o)
and o["model_reported"] is not None
and o["model_reported"] not in allowed
}
)
async def _anchor_off_against_on(
model: str, off_observations: list[dict]
) -> tuple[list[dict], Effort | None, bool]:
"""判据④: 拿"开启档的 completion 明显更大"给关闭结论补一个正面证据。
需要它是因为 `UNKNOWN` 的语义: 它是"本次没有任何信号,判不出来",不是"没推理"
(`observe_thinking` docstring 把这条写死了)kimi MiniMax 这两路上游都
不回传 `completion_tokens_details`,关闭档整片 `UNKNOWN`此时若直接把"没看见"
读成"关掉了",库就会登记一个自己从未验证过的 `none`,而下游据此以为省了钱
锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含
任何魔数**: 推理段计在 completion ,真开着时两档差一个数量级(实测 kimi-k3
关闭档恒 9 token) `max` 档而非 `auto`: 后者在 `on_base={}` provider
等于"什么都不注入",那是模型默认档而不是"",拿它当对照组会把 M3 这种默认不推理的
模型判成"分不开"(minimax 段已按 issue #21 改回带 medium,openai/anthropic/google
三段仍是空片段,故该风险仍在)`max` 打不通时才退到 `auto`
"""
off_usable = [o for o in off_observations if _probe_ok(o)]
for tier in (Effort.MAX, Effort.AUTO):
anchor = await _probe_effort(
model,
tier,
rounds=_TIER_LONG_ROUNDS,
prompt=_TIER_PROMPT,
prompt_kind=f"anchor({tier.value})",
)
on_usable = [o for o in anchor if _probe_ok(o)]
if not on_usable:
continue
off_max = max(o["completion_tokens"] for o in off_usable)
on_min = min(o["completion_tokens"] for o in on_usable)
return anchor, tier, off_max < on_min
return [], None, False
def _probe_record(model: str, phase: str, verdict: str, detail: str, observations: list[dict]):
_PROBE_ROWS.append(
{
"model": model,
"provider": _MODEL_PROVIDER[model],
"phase": phase,
"verdict": verdict,
"detail": detail,
"observations": observations,
}
)
@pytest.fixture(scope="module", autouse=True)
def _write_tier_report():
"""T10 报告独立成文件: 它的读者是"能力表该怎么改",与 L1-L9 的"库对不对"不同。"""
yield
if not _PROBE_ROWS:
return
_TIER_OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _TIER_OUT_DIR / f"tier_probe_{ts}.md"
lines = [
"# 推理档位能力表实测(T10,经 new-api 中转)",
"",
f"- 时间: {ts}",
f"- 短提示词轮数: {_TIER_ROUNDS};长上下文复核轮数: {_TIER_LONG_ROUNDS};"
f"并发: {_TIER_CONCURRENCY}(共用生产网关,宁慢勿冲)",
f"- 短提示词: `{_TIER_PROMPT}`",
f"- 长上下文: 同题 + {len(_TIER_LONG_PROMPT)} 字符无关填充(判据②)",
"- 判据: 关闭方向要求**每轮**未观测到推理,且短提示词的「关掉了」必须经长上下文复核;"
"开启方向要求多数轮 OBSERVED",
"- 能力表在探测时被临时替换为全档表: 实测的对象正是它,不能拿它挡请求",
"",
"## 逐模型结论",
"",
"| 模型 | provider | 阶段 | 结论 | 观测 |",
"|---|---|---|---|---|",
]
total = 0
for row in _PROBE_ROWS:
detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:220]
lines.append(
f"| {row['model']} | {row['provider']} | {row['phase']} | {row['verdict']} | {detail} |"
)
total += len(row["observations"])
lines += ["", f"**总真实调用次数: {total}**", "", "## 逐轮原始观测", ""]
for row in _PROBE_ROWS:
if not row["observations"]:
continue
lines += [f"### {row['model']}{row['phase']}", "", "```json"]
lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2, default=str))
lines += ["```", ""]
path.write_text("\n".join(lines), encoding="utf-8")
print(f"\n[T10 报告] {path}")
class TestTierProbe:
"""能力表实测。可只跑单个模型: `-k "test_t10 and glm-5.3"`。"""
@pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER))
async def test_t10_none_direction_matches_declaration(self, model):
"""「这个模型到底关不关得掉」——能力表里唯一会**报错**的那条声明。
它是本节最要紧的一条: `Effort.NONE` 在不在清单里,决定 Phase 4 是放行还是
当场报错声明错了,两个方向的代价都很实在多写了 `none` 会让下游以为
关掉了(issue #20 的静默失效),漏写了会把一条本来可用的路堵死。
"""
short = await _probe_effort(
model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short"
)
# 上游拒绝这一档(400)是**结论**而非故障: 它等价于"关不掉";
# 其余失败(渠道下线/超时)才是源不可用,按既有纪律记为未覆盖
rejected = [o for o in short if o["error"] and o["error"].startswith("RequestRejected")]
usable = [o for o in short if _probe_ok(o)]
# **按可用轮判,而不是一有失败就整条跳过**: 共用网关上偶发 429/503 是常态,
# 一票否决会让整张表因为一次抖动而没有数据。样本低于 3 轮才是真的没结论
if not rejected and len(usable) < min(3, _TIER_ROUNDS):
broken = [o for o in short if o["error"]]
_probe_record(model, "none 方向", "SKIP(源不可用)", _rt_summary(short), short)
pytest.skip(f"{model} 源不可用,已记为未覆盖: {broken[0]['error'][:120]}")
strangers = _identity_mismatch(model, short)
if strangers:
_probe_record(
model,
"none 方向",
"SKIP(身份不符,数据不可信)",
f"该渠道把请求回报成 {strangers};{_rt_summary(short)}",
short,
)
pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型")
observations = list(short)
measured_can_disable = not rejected and all(_probe_quiet(o) for o in usable)
note = ""
if measured_can_disable:
# 判据②: 短提示词下"看起来关了"必须过长上下文这一关
long_ctx = await _probe_effort(
model,
Effort.NONE,
rounds=_TIER_LONG_ROUNDS,
prompt=_TIER_LONG_PROMPT,
prompt_kind="long",
)
observations += long_ctx
usable = [o for o in long_ctx if _probe_ok(o)]
if not usable:
note = ";长上下文复核未跑通,结论只在短提示词下成立"
else:
measured_can_disable = all(_probe_quiet(o) for o in usable)
note = ";长上下文复核" + ("同样未观测到推理" if measured_can_disable else "露馅")
if measured_can_disable and not any(
o["thinking_observation"] is ThinkingObservation.ABSENT for o in observations
):
# 判据④: 全程 `UNKNOWN` 时,"关掉了"是一句没有正面证据的话
anchor, anchor_tier, separable = await _anchor_off_against_on(model, observations)
observations += anchor
if anchor_tier is None:
note += ";锚点未跑通,关闭结论缺正面证据"
elif separable:
note += f";锚点可分(关闭档 completion 严格小于 {anchor_tier.value} 档)"
else:
measured_can_disable = None
note += f";**锚点不可分**(与 {anchor_tier.value} 档的 completion 分不开),判不出来"
detail = f"实测 can_disable={measured_can_disable}{note}。短: {_rt_summary(short)}" + (
f" ‖ 后续: {_rt_summary(observations[len(short) :])}"
if len(observations) > len(short)
else ""
)
if measured_can_disable is None:
_probe_record(model, "none 方向", "INCONCLUSIVE(无正面证据)", detail, observations)
pytest.skip(f"{model} 判不出来,已记为未覆盖: {detail[:160]}")
capability = get_capability(model)
if capability is None:
_probe_record(model, "none 方向", "DATA(未登记)", detail, observations)
pytest.skip(f"{model} 未登记(设计 §8 第三档),本条只采数据: {detail[:120]}")
agrees = measured_can_disable == capability.can_disable
_probe_record(
model,
"none 方向",
"PASS" if agrees else "FAIL(能力表已漂移)",
f"声明 can_disable={capability.can_disable};{detail}",
observations,
)
assert agrees, (
f"{model} 的能力表与实测不符: 声明 can_disable={capability.can_disable},"
f"实测 {measured_can_disable}{detail}"
)
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
async def test_t10_declared_tiers_actually_reason(self, model):
"""已登记的每个**开启档**都必须被上游接受,且真的推理。
证伪力只在"被拒""没推理"两件事上**不断言档位之间的 rt 高低**:
设计 §4.3 已定,同一档 rt 实测在 8~56 之间跳,拿它比大小必然是噪声
故本条能证伪的是"登记了一个上游根本不认的档",不是"档位排序对不对"
"""
capability = get_capability(model)
tiers = [e for e in capability.supported_efforts if e is not Effort.NONE]
if not tiers:
pytest.skip(f"{model} 只登记了 none,没有开启档可验")
failures = []
for tier in tiers:
observations = await _probe_effort(
model, tier, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short"
)
rejected = [
o for o in observations if o["error"] and o["error"].startswith("RequestRejected")
]
usable = [o for o in observations if _probe_ok(o)]
observed = [o for o in observations if _probe_observed(o)]
strangers = _identity_mismatch(model, observations)
if strangers:
# 与 none 方向同一条纪律: 回报的不是这个模型,这组数就不是它的
_probe_record(
model,
f"档位 {tier.value}",
"SKIP(身份不符,数据不可信)",
f"该渠道把请求回报成 {strangers};{_rt_summary(observations)}",
observations,
)
pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型")
if rejected:
verdict, problem = "FAIL(上游拒绝该档)", f"{tier.value}: 上游拒绝"
elif not usable:
verdict, problem = "SKIP(源不可用)", None
elif len(observed) * 2 > len(usable):
verdict, problem = "PASS", None
else:
verdict, problem = "FAIL(该档未推理)", f"{tier.value}: 多数轮未观测到推理"
if problem:
failures.append(problem)
_probe_record(
model, f"档位 {tier.value}", verdict, _rt_summary(observations), observations
)
assert not failures, f"{model} 登记的档位与实测不符: {failures}"
@pytest.mark.parametrize("model", ["gemini-3.1-pro", "gpt-5.5", "glm-5.3"])
async def test_t10_no_opinion_stays_no_opinion(self, model):
"""不表态时库**不推定**模型自己的默认档(Phase 1),顺带采下默认档的 rt 基线。
为什么给这三个模型单列一条: 它们的厂商默认档 evidence 里写着却最容易
写错的一格(Gemini 3.1 Pro 官方文档说 HIGHOpenRouter medium,两源打架),
而默认档写错会误导下游估成本库本身不依赖这个值**它不表态就什么都不注入**,
这正是本条断言的东西;默认档的 rt 观测只作报告里的旁证,**不作断言**: 单一模型上
rt 与档位没有可判定的函数关系(设计 §4.3),拿它反推默认档只能存疑,不能定论
2026-09-05: gemini 一路当下在本渠道上游报错,claude 一路 7 天限额用尽,故把
另两格换成当下可测的 gpt-5.5 glm-5.3;gemini 留着,渠道恢复即有数
"""
client = GatewayClient.from_settings(
_tier_settings(model), capabilities=_probe_capabilities(model)
)
observations = []
try:
for i in range(_TIER_ROUNDS):
try:
resp = await client.chat(
[{"role": "user", "content": _TIER_PROMPT}],
stream=True,
cache_salt=f"tier-default-{model}-{i}",
)
except (
RequestRejectedError,
GatewayUnavailableError,
SourceDeadError,
TransientError,
) as exc:
observations.append(
{
"round": i + 1,
"effort": "(不表态)",
"prompt_kind": "short",
"error": f"{type(exc).__name__}: {str(exc)[:160]}",
}
)
continue
observations.append(
{
"round": i + 1,
"effort": "(不表态)",
"prompt_kind": "short",
"error": None,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
"thinking_chars": len(resp.thinking),
"thinking_observation": resp.thinking_observation,
"applied_effort": resp.applied_effort,
"model_reported": resp.model_reported,
"content": resp.content[:40],
}
)
finally:
await client.aclose()
usable = [o for o in observations if _probe_ok(o)]
if not usable:
_probe_record(
model,
"默认档基线(不表态)",
"SKIP(源不可用)",
_rt_summary(observations),
observations,
)
pytest.skip(f"{model} 源不可用,已记为未覆盖: {observations[0]['error'][:120]}")
leaked = [o for o in usable if o["applied_effort"] is not None]
_probe_record(
model,
"默认档基线(不表态)",
"PASS" if not leaked else "FAIL(库替模型推定了默认档)",
_rt_summary(observations),
observations,
)
assert not leaked, f"{model}: 不表态时 applied_effort 应为 None,实测 {leaked}"
+248
View File
@@ -0,0 +1,248 @@
"""PG 集成测试的一次性沙箱工厂(issue #18)。
**为什么把它收敛成一份**: 在此之前,"建临时 schema → 挂 search_path → teardown
删净"这套样板在两个测试文件里重复了七处,清理逻辑各写各的——任何一处写漏,残留都
落在与真实批跑共用的那个库上工厂让清理只有一份实现,并让"用例拿不到管理连接"
成为结构事实而不是纪律
**admin DSN 不做成 fixture**: 它能对共享表执行任何语句做成 fixture 等于把这个
能力摆在每一条用例面前,"用例不该直接用"就只是一句提醒故它是模块私有函数,
只被工厂内部调用,`PgSandbox` 也不携带它
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from uuid import uuid4
import pytest
from dotenv import dotenv_values
if TYPE_CHECKING:
from collections.abc import Sequence
# 测试专用口令: 这些角色只在单条用例的生命周期内存在,且只对自建 schema 有权。
# 它不是机密,写死在这里比走 .env 更清楚——.env 里的每一项都该是真实部署会用的。
_SANDBOX_PASSWORD = "pgw-sandbox-not-a-secret" # noqa: S105
_Role = Literal["none", "owner", "grantee"]
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str
"""已挂 `options=-csearch_path=<schema>`,用例默认用它。"""
bare_dsn: str | None
"""同角色但**不挂** search_path(回落 `"$user", public`);`role="none"` 时为 None。"""
def _admin_dsn() -> str | None:
"""读 `.env` 的 `PGW_TELEMETRY_PG_DSN` 并剥掉 SQLAlchemy 风格的 `+driver` 后缀。"""
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _require_admin_dsn() -> str:
"""取管理连接串;未配置则 skip,连错库则 fail(不是 skip)。
库名守卫不肯降级成 skip: 这个实例上还有 app/chs_prod 等在用库,"连错库"
悄悄跳过,等于让一次配置事故以"没跑那些测试"的形态过关
"""
value = _admin_dsn()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"PG 集成测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
def _with_search_path(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _as_role(dsn: str, role: str) -> str:
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
.pgpass / trust) `postgresql:///db?host=/var/run/postgresql`(unix socket)
都是合法 DSN,却没有可替换的内联凭据段静默返回原串的后果不是测试报错,而是
沙箱以**管理身份**建成用例照常绿,同时 `bare_dsn` 变成超级用户连接最坏
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红
这正是 P5"严禁默认值掩盖错误"要挡的形态
"""
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
if count != 1:
raise RuntimeError(
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
)
return swapped
@pytest.fixture
async def pg_catalog_probe():
"""只读地查 PG catalog,**仅供工厂自测核对残留**,不是通用查询入口。
它拿的是管理连接,故有意只暴露给 `test_pg_sandbox.py` 这一类"验证隔离本身
是否成立"的用例;业务断言一律走 `PgSandbox.dsn`。
"""
import asyncpg
dsn = _require_admin_dsn()
async def probe(sql: str, *args: object) -> list[tuple]:
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
# 管理能力"这句话降格成一句 docstring 里的请求。
if not sql.lstrip().upper().startswith("SELECT"):
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
conn = await asyncpg.connect(dsn, timeout=10)
try:
return [tuple(r) for r in await conn.fetch(sql, *args)]
finally:
await conn.close()
return probe
@pytest.fixture
async def pg_sandbox():
"""一次性沙箱工厂: `await pg_sandbox(ddl=..., role=...)`,清理由 fixture 兜底。
同一条用例可以要多个沙箱("A 的角色去动 B 的表"),它们按后进先出清理
"""
import asyncpg
admin_dsn = _require_admin_dsn()
# 清理动作栈: 每建成一个对象就入栈一条,setup 中途失败与正常 teardown 共用
# 同一条退栈路径——两处各写一份的话,失败那条永远是没被测过的那份。
cleanups: list[str] = []
async def _run_as_admin(*statements: str) -> None:
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
for statement in statements:
await conn.execute(statement)
finally:
await conn.close()
async def _unwind(statements: list[str]) -> None:
"""逆序执行清理并**逐条容错**: 一条失败不该拖累其余对象的清理。
吞掉异常是不行的(残留会静默累积),但让第一条失败中断整栈更糟角色是
全局对象,漏掉的每一个都要人手工去删故全部试完再抛出第一个异常
"""
first: BaseException | None = None
for statement in reversed(statements):
try:
await _run_as_admin(statement)
except Exception as exc: # noqa: BLE001 — 见 docstring: 收集而非吞没
first = first or exc
statements.clear()
if first is not None:
raise first
async def make(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: _Role = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox:
# 权限门在建任何对象**之前**: pytest.skip 抛的是 BaseException,若它在
# 已建对象之后触发,清理会去 DROP 从未建成的东西并把 skip 盖掉。
if role != "none":
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
can_create = await conn.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
)
finally:
await conn.close()
if not can_create:
pytest.skip("当前账号无权建临时角色,跳过需要独占角色的用例")
# schema 与角色的前缀有意不同: 同名会让 "$user" 命中自有 schema 并遮蔽
# 共享表,于是"search_path 落到共享表"这个最坏情况就再也构造不出来。
suffix = uuid4().hex[:12]
schema = f"pgw_s_{suffix}"
role_name = f"pgw_r_{suffix}" if role != "none" else None
# 本次调用自己的清理栈: 失败只回滚**本次**建成的对象。同一条用例常要两个
# 沙箱(如"A 的角色去动 B 的表"),回滚整栈会把已通过断言依赖的对象也删掉。
local: list[str] = []
try:
if role_name is not None:
await _run_as_admin(f"CREATE ROLE {role_name} LOGIN PASSWORD '{_SANDBOX_PASSWORD}'")
# DROP OWNED BY 必须排在 DROP ROLE 之前: 角色仍持有对象时删不掉
local.append(f"DROP ROLE IF EXISTS {role_name}")
local.append(f"DROP OWNED BY {role_name}")
owner_clause = f" AUTHORIZATION {role_name}" if role == "owner" else ""
await _run_as_admin(f"CREATE SCHEMA {schema}{owner_clause}")
local.append(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
bare = _as_role(admin_dsn, role_name) if role_name is not None else None
# role="owner" 时 DDL 由角色自己执行,表属主才会是它;"grantee" 的现场
# 恰恰相反——表由别的账号建好,角色只拿到表级权限。
ddl_dsn = _with_search_path(bare if role == "owner" else admin_dsn, schema)
if ddl is not None:
conn = await asyncpg.connect(ddl_dsn, timeout=10)
try:
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
if role == "grantee":
await _run_as_admin(f"GRANT USAGE ON SCHEMA {schema} TO {role_name}")
if ddl is not None:
await _run_as_admin(
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
)
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
used = bare if role_name is not None else admin_dsn
sandbox = PgSandbox(
schema=schema,
role=role_name,
dsn=_with_search_path(used, schema),
bare_dsn=bare,
)
if role_name is not None:
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
# IF EXISTS,第二遍必报错)。
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
actual = await conn.fetchval("SELECT current_user")
finally:
await conn.close()
if actual != role_name:
raise RuntimeError(
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
"权限边界不成立,拒绝把这个沙箱交出去。"
)
except BaseException:
await _unwind(local)
raise
cleanups.extend(local)
return sandbox
yield make
await _unwind(cleanups)
+201
View File
@@ -0,0 +1,201 @@
"""`conftest.py` 沙箱工厂自身的行为测试(issue #18 Task 1)。
工厂是本次一切隔离的地基: 它若在 setup 中途失败时漏掉清理或让角色名与
schema 名撞上,受害的不是这一个文件,而是此后每一条 PG 用例故它必须先被测
**这里的断言全部只看自建对象与 PG catalog**,不读任何共享数据
"""
from __future__ import annotations
import pytest
from tests.integration.conftest import _as_role
_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
@pytest.fixture
async def assert_no_leftovers(pg_catalog_probe):
"""收集沙箱名,在 `pg_sandbox` 清理之后回查它们是否真的没了。
必须比 `pg_sandbox` **** setup: pytest finalizer 是后进先出, setup
的后 teardown fixture 的检查因此发生在沙箱清理之后,而不是之前
"""
seen: list[tuple[str, str | None]] = []
yield seen
for schema, role in seen:
left = await pg_catalog_probe("SELECT nspname FROM pg_namespace WHERE nspname = $1", schema)
assert left == [], f"沙箱 schema 未清理: {schema}"
if role is not None:
left = await pg_catalog_probe("SELECT rolname FROM pg_roles WHERE rolname = $1", role)
assert left == [], f"沙箱角色未清理: {role}"
async def _oid_of_llm_calls(dsn: str) -> int | None:
import asyncpg
conn = await asyncpg.connect(dsn, timeout=10)
try:
return await conn.fetchval("SELECT to_regclass('llm_calls')::oid")
finally:
await conn.close()
class TestRoleDsnConstruction:
"""凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。
这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱
"看起来"建好了用例照常绿, `bare_dsn` 其实是超级用户最坏情况用例
会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红
行已经没了设计 §5.1 要的是"越界做不到",不是"越界会被发现"
"""
def test_inline_credentials_are_replaced(self):
swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x")
assert swapped.startswith("postgresql://pgw_r_x:")
assert "app:secret" not in swapped
@pytest.mark.parametrize(
"dsn",
[
"postgresql://h:5432/polygateway", # 口令走 PGPASSWORD / .pgpass / trust
"postgresql:///polygateway?host=/var/run/postgresql", # unix socket
],
)
def test_a_dsn_without_inline_credentials_is_refused(self, dsn):
"""这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。"""
with pytest.raises(RuntimeError, match="沙箱角色"):
_as_role(dsn, "pgw_r_x")
class TestCatalogProbeIsReadOnly:
"""探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。"""
async def test_non_select_statements_are_refused(self, pg_catalog_probe):
with pytest.raises(RuntimeError, match="只接受 SELECT"):
await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'")
class TestSchemaOnlySandbox:
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
sandbox = await pg_sandbox(ddl=_DDL)
import asyncpg
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
where = await conn.fetchval(
"SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass('llm_calls')"
)
finally:
await conn.close()
assert where == sandbox.schema
assert sandbox.role is None
assert sandbox.bare_dsn is None
class TestOwnerRoleSandbox:
async def test_the_role_owns_its_own_table(self, pg_sandbox):
"""`role="owner"`: 表由角色自己建,故属主是它——与"用维护角色跑"的现场一致。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
import asyncpg
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
owner = await conn.fetchval(
"SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = to_regclass('llm_calls')"
)
finally:
await conn.close()
assert owner == sandbox.role
# 名字必须错开: 同名会让 "$user" 命中自有 schema 并遮蔽真表,
# 最坏情况用例就再也走不到那条真实路径上(设计 §5.1 实测)
assert sandbox.role != sandbox.schema
assert not sandbox.role.startswith("pgw_s_")
assert not sandbox.schema.startswith("pgw_r_")
async def test_bare_dsn_falls_through_to_the_default_search_path(self, pg_sandbox):
"""裸 DSN 必须真的回落到 `"$user", public`——最坏情况用例全靠它构造现场。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
import asyncpg
conn = await asyncpg.connect(sandbox.bare_dsn, timeout=10)
try:
path = await conn.fetchval("SHOW search_path")
finally:
await conn.close()
assert path == '"$user", public'
# 裸 DSN 解析到的绝不能是沙箱里那张表(否则"落到共享表"的现场是假的)
assert await _oid_of_llm_calls(sandbox.bare_dsn) != await _oid_of_llm_calls(sandbox.dsn)
class TestGranteeRoleSandbox:
async def test_grantee_can_write_but_cannot_create(self, pg_sandbox):
"""`role="grantee"`: 表属主是 admin,角色只拿表级权限——最小权限部署的现场。"""
import asyncpg
sandbox = await pg_sandbox(ddl=_DDL, role="grantee")
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
await conn.execute("INSERT INTO llm_calls (call_id) VALUES ('g1')")
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 1
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("CREATE TABLE another (x TEXT)")
finally:
await conn.close()
class TestCleanup:
async def test_setup_failure_leaves_nothing_behind(self, pg_sandbox, pg_catalog_probe):
"""建到一半失败时也必须删净——角色是**全局**对象,残留不随库消失。"""
before_schemas = await pg_catalog_probe(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
)
before_roles = await pg_catalog_probe(
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
)
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
assert (
await pg_catalog_probe(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
)
== before_schemas
)
assert (
await pg_catalog_probe(
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
)
== before_roles
)
async def test_a_failure_does_not_roll_back_earlier_sandboxes(self, pg_sandbox):
"""一次失败只回滚它自己建的东西——同一条用例里先建成的沙箱必须毫发无损。
"A 的角色去动 B 的表"这类用例一条要两个沙箱;若失败回滚把整栈清空,受害的
是那些**已经通过**的断言所依赖的对象,而症状会以"表不见了"的形态出现在
与真因无关的地方
"""
good = await pg_sandbox(ddl=_DDL, role="owner")
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
assert await _oid_of_llm_calls(good.dsn) is not None, "先前建成的沙箱被误清理"
async def test_teardown_removes_schema_and_role(self, assert_no_leftovers, pg_sandbox):
"""正常路径的清理: 断言发生在 `pg_sandbox` teardown **之后**(见 fixture 说明)。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
assert_no_leftovers.append((sandbox.schema, sandbox.role))
+235 -276
View File
@@ -1,11 +1,15 @@
"""PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。
DSN .env `PGW_TELEMETRY_PG_DSN`,缺则 skip该实例上有 app/chs_prod
在用库本测试只允许连 polygateway 专用库(fixture 里守卫)
在用库本测试只允许连 polygateway 专用库(`conftest.py` 的工厂里守卫)
隔离纪律(M4 事故教训): `llm_calls` 是与真实批跑/迁移项目共享的表,
**严禁 DROP/TRUNCATE**本测试以 run call_id 前缀隔离,断言只看
自己写入的行,teardown 只删自己的行
隔离纪律(issue #18): 本文件对共享表 `llm_calls` **零触碰**——每条用例都在
`pg_sandbox` 建的一次性 schema 里跑,/删都只发生在自己的 schema
此前那套 run call_id 前缀隔离已随之删除: schema 隔离完全取代了它,
两套并存只会让"这一行归谁"重新变成需要论证的事
**唯一的例外是连接**: 连接是实例级共享资源,schema 隔离对它无效,
`TestPoolFootprint` 仍靠一个就地生成的唯一 `application_name` 认领本池连接
"""
from __future__ import annotations
@@ -52,17 +56,13 @@ _EXPECTED_COLUMNS = [
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
_RUN_PREFIX = f"pgwtest-{uuid4().hex[:8]}"
def _cid(suffix: str) -> str:
return f"{_RUN_PREFIX}-{suffix}"
def _dsn() -> str | None:
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
@@ -72,23 +72,24 @@ def _dsn() -> str | None:
@pytest.fixture
async def dsn():
async def template_admin_dsn() -> str:
"""管理连接串,**只服务 `production_template` 一个 fixture**。
它没有随其余六个 fixture 一起收敛到 `pg_sandbox`,是因为 `production_template`
要自建三个角色 README 解析出的整套模板 SQL按月建分区,权限语义与失败期
清理都是它自己的(设计 §7.1 末段),工厂强行接管会把这些语义压扁
名字不叫 `dsn`: `dsn` 等于把一个能动共享表的连接摆在每条用例的参数位上,
而设计 §7.1 约束 3 要的正是"用例拿不到管理连接"此处的窄命名是那条约束在
本文件能做到的最接近的形态
"""
value = _dsn()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
yield value
# teardown: 只删本 run 写入的行;表可能尚不存在(全新库)则忽略
import asyncpg
conn = await asyncpg.connect(value, timeout=10)
try:
if await conn.fetchval("SELECT to_regclass('llm_calls')") is not None:
await conn.execute("DELETE FROM llm_calls WHERE call_id LIKE $1", f"{_RUN_PREFIX}-%")
finally:
await conn.close()
return value
async def _record_minimal(
@@ -100,7 +101,7 @@ async def _record_minimal(
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证
"""
fields: dict[str, object] = {
"call_id": call_id if call_id is not None else _cid("c1"),
"call_id": call_id if call_id is not None else "c1",
"parent_call_id": None,
"session_id": "sess-1",
"model": "m",
@@ -125,6 +126,10 @@ async def _record_minimal(
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None,
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -173,8 +178,9 @@ async def _execute_script(dsn: str, sql: str) -> None:
await conn.close()
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_LEGACY_DDL = """
CREATE TABLE {schema}.llm_calls (
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
@@ -199,56 +205,48 @@ CREATE TABLE {schema}.llm_calls (
@pytest.fixture
async def legacy_schema(dsn):
"""**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。
async def legacy_schema(pg_sandbox) -> tuple[str, str]:
"""一次性沙箱 schema 里造一张 18 列旧表,验证补列(issue #3)。
绝不碰共享 public.llm_calls: search_path recorder 指向临时 schema,
teardown DROP 自己建的 schema
共享 `llm_calls` 一个字节都不碰: recorder search_path 指向沙箱 schema,
清理由工厂统一兜底
"""
import asyncpg
name = f"pgwtest_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_LEGACY_DDL.format(schema=name))
finally:
await conn.close()
sep = "&" if "?" in dsn else "?"
yield f"{dsn}{sep}options=-csearch_path%3D{name}", name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
sandbox = await pg_sandbox(ddl=_LEGACY_DDL)
return sandbox.dsn, sandbox.schema
class TestObservabilityColumns:
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
async def test_values_round_trip(self, dsn):
recorder = _recorder(dsn, auto_migrate=True)
async def test_values_round_trip(self, pg_sandbox):
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01")
await _record_minimal(recorder, call_id="hit", cached_prompt_tokens=64)
await _record_minimal(recorder, call_id="zero", cached_prompt_tokens=0)
await _record_minimal(recorder, call_id="model", model_reported="MiniMax-01")
await _record_minimal(
recorder, call_id=_cid("samp"), sampling='{"seed": 42, "temperature": 0}'
recorder, call_id="samp", sampling='{"seed": 42, "temperature": 0}'
)
await _record_minimal(recorder, call_id="tier", reasoning_effort="low")
rows = await _fetch(
dsn,
"SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls "
"WHERE call_id LIKE $1",
f"{_RUN_PREFIX}-%",
sandbox.dsn,
"SELECT call_id, cached_prompt_tokens, model_reported, sampling, "
"reasoning_effort FROM llm_calls WHERE call_id = ANY($1::text[])",
["hit", "zero", "model", "samp", "tier"],
)
by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
assert by_id[_cid("model")]["cached_prompt_tokens"] is None
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01"
assert by_id["hit"]["cached_prompt_tokens"] == 64
assert by_id["zero"]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
assert by_id["model"]["cached_prompt_tokens"] is None
assert by_id["model"]["model_reported"] == "MiniMax-01"
# issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在)
assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0}
assert by_id[_cid("hit")]["sampling"] is None
assert json.loads(by_id["samp"]["sampling"]) == {"seed": 42, "temperature": 0}
assert by_id["hit"]["sampling"] is None
# issue #20: PG 侧同样要验档位读得回来——emitter 落的是裸 str,
# 若哪天回退成 `Effort` 实例,asyncpg 编码不保证接受,写入会整行降级
assert by_id["tier"]["reasoning_effort"] == "low"
assert by_id["hit"]["reasoning_effort"] is None # 不表态是 NULL
finally:
await recorder.aclose()
@@ -258,7 +256,7 @@ class TestObservabilityColumns:
recorder = _recorder(schema_dsn, auto_migrate=True)
try:
await _record_minimal(
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
recorder, call_id="legacy", cached_prompt_tokens=7, model_reported="m-real"
)
cols = await _fetch(
schema_dsn,
@@ -271,7 +269,7 @@ class TestObservabilityColumns:
rows = await _fetch(
schema_dsn,
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1",
_cid("legacy"),
"legacy",
)
assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
finally:
@@ -279,42 +277,44 @@ class TestObservabilityColumns:
class TestSchema:
async def test_schema_has_frozen_columns_in_order(self, dsn):
recorder = _recorder(dsn, auto_migrate=True)
async def test_schema_has_frozen_columns_in_order(self, pg_sandbox):
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try:
await _record_minimal(recorder)
rows = await _fetch(
dsn,
sandbox.dsn,
# `table_schema = $1` 不可省: 不带它,库里任何一个残留 schema 下的同名表
# 都会把自己的列拼进结果,这条断言于是以"列数不符"的形态被别人的残留误伤
"SELECT column_name FROM information_schema.columns "
"WHERE table_name='llm_calls' ORDER BY ordinal_position",
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
sandbox.schema,
)
assert [r["column_name"] for r in rows] == _EXPECTED_COLUMNS
finally:
await recorder.aclose()
async def test_call_id_idempotent(self, dsn):
recorder = _recorder(dsn, auto_migrate=True)
async def test_call_id_idempotent(self, pg_sandbox):
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("dup"))
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
await _record_minimal(recorder, call_id="dup")
await _record_minimal(recorder, call_id="dup", response="second")
rows = await _fetch(
dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("dup")
sandbox.dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "dup"
)
assert [r["response"] for r in rows] == ["ok"] # ON CONFLICT DO NOTHING
finally:
await recorder.aclose()
async def test_concurrent_writes_all_land(self, dsn):
recorder = _recorder(dsn, auto_migrate=True)
async def test_concurrent_writes_all_land(self, pg_sandbox):
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try:
await asyncio.gather(
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
)
rows = await _fetch(
dsn,
"SELECT count(*) AS n FROM llm_calls WHERE call_id LIKE $1",
f"{_RUN_PREFIX}-c%",
)
await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
# 沙箱 schema 里只有这一批行,故全表 COUNT 就是本用例写入的行数——
# 前缀过滤在这里已无事可做(它当年存在只是为了从共享表里认领自己的行)
rows = await _fetch(sandbox.dsn, "SELECT count(*) AS n FROM llm_calls")
assert rows[0]["n"] == 50
finally:
await recorder.aclose()
@@ -338,7 +338,7 @@ class TestDegradation:
"""服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。"""
recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
await _record_minimal(recorder) # 不抛
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
await _record_minimal(recorder, call_id="c2") # 已降级短路,同样不抛
await recorder.aclose()
async def test_refused_connection_cools_down_and_retries_after_cooldown(self):
@@ -361,7 +361,7 @@ class TestDegradation:
now=clock,
)
try:
await _record_minimal(recorder, call_id=_cid("deg1"))
await _record_minimal(recorder, call_id="deg1")
first = recorder.telemetry_status
# 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死,
# 整进程从此一行遥测都不落、只有重启能恢复
@@ -372,14 +372,14 @@ class TestDegradation:
assert "建表探测失败" in (first.reason or "")
clock.advance(30.0)
await _record_minimal(recorder, call_id=_cid("deg2"))
await _record_minimal(recorder, call_id="deg2")
mid = recorder.telemetry_status
# 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路)
assert mid.retry_after_s == pytest.approx(30.0)
assert mid.dropped_rows == 2
clock.advance(30.1)
await _record_minimal(recorder, call_id=_cid("deg3"))
await _record_minimal(recorder, call_id="deg3")
after = recorder.telemetry_status
# 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈)
assert after.retry_after_s == pytest.approx(60.0)
@@ -388,23 +388,25 @@ class TestDegradation:
finally:
await recorder.aclose()
async def test_row_failure_does_not_poison_later_rows(self, dsn):
async def test_row_failure_does_not_poison_later_rows(self, pg_sandbox):
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
recorder = _recorder(dsn, auto_migrate=True)
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
await _record_minimal(recorder, call_id=_cid("good"))
await _record_minimal(recorder, call_id="bad", response="nul\x00byte")
await _record_minimal(recorder, call_id="good")
rows = await _fetch(
dsn,
sandbox.dsn,
"SELECT call_id FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("bad"), _cid("good")],
["bad", "good"],
)
assert [r["call_id"] for r in rows] == [_cid("good")]
assert [r["call_id"] for r in rows] == ["good"]
finally:
await recorder.aclose()
async def test_aclose_idempotent(self, dsn):
recorder = _recorder(dsn, auto_migrate=True)
async def test_aclose_idempotent(self, pg_sandbox):
sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
await _record_minimal(recorder)
await recorder.aclose()
await recorder.aclose()
@@ -424,7 +426,7 @@ def _tagged(dsn: str, app_name: str) -> str:
async def _pool_backend_count(dsn: str, app_name: str) -> int:
"""数**本池**在服务端的连接数(只读查询,不改实例任何状态)。
只按 run 级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
只按用例级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例
(CLAUDE.md §4.6)本查询自己那条连接走未打 tag DSN,故不会数到自己
"""
@@ -455,73 +457,53 @@ class TestPoolFootprint:
那么多连接"——两件事,只有真实 PG 能证后者。
"""
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, dsn):
app_name = f"{_RUN_PREFIX}-pool" # run 级唯一,与并跑的其他运行互不可见
recorder = _recorder(_tagged(dsn, app_name), auto_migrate=True)
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, pg_sandbox):
sandbox = await pg_sandbox()
# `application_name` 的唯一性必须**就地**造,不能跟着行隔离前缀一起删掉:
# 连接是实例级资源,schema 隔离对 `pg_stat_activity` 完全无效,换成固定名字
# 会把并跑进程的连接数进来,等于把偶发红从表层搬到连接层(设计 §6.1)。
app_name = f"pgwtest-pool-{uuid4().hex[:12]}"
recorder = _recorder(_tagged(sandbox.dsn, app_name), auto_migrate=True)
try:
# 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前
# `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10
assert await _pool_backend_count(dsn, app_name) == 0
assert await _pool_backend_count(sandbox.dsn, app_name) == 0
await _record_minimal(recorder, call_id=_cid("fp1"))
await _record_minimal(recorder, call_id="fp1")
# **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功),
# 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。
# 故这是个确定值,不是"某一刻恰好的采样"
assert await _pool_backend_count(dsn, app_name) == 1
assert await _pool_backend_count(sandbox.dsn, app_name) == 1
await asyncio.gather(
*(_record_minimal(recorder, call_id=_cid(f"fp{i}")) for i in range(2, 22))
*(_record_minimal(recorder, call_id=f"fp{i}") for i in range(2, 22))
)
steady = await _pool_backend_count(dsn, app_name)
steady = await _pool_backend_count(sandbox.dsn, app_name)
# 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池,
# 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过
assert 1 <= steady <= _POOL_MAX
finally:
await recorder.aclose()
assert await _settled_backend_count(dsn, app_name) == 0 # 关闭即归还全部连接
# 关闭即归还全部连接
assert await _settled_backend_count(sandbox.dsn, app_name) == 0
_PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据
@pytest.fixture
async def least_privilege_dsn(dsn):
"""临时 schema + 临时角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
async def least_privilege_dsn(pg_sandbox) -> tuple[str, str]:
"""一次性 schema + 独占角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
这是 issue #9 的现场——最小权限部署的标准形态。fixture 建的一切
(schema角色)都在 teardown 里删净,共享的 public.llm_calls 不受影响;
连不上或无权建角色(非超级用户) skip,不让 CI 假绿
这是 issue #9 的现场——最小权限部署的标准形态。`role="grantee"` 的语义恰是它:
表由 admin 建好(属主不是应用账号),角色只拿到 `USAGE` 加表级 grants,
唯独没有 `CREATE ON SCHEMA`缺的正是这一项
无权建角色(非超级用户)时工厂自己 skip,不让 CI 假绿
"""
import asyncpg
from polygateway.telemetry.schema import PG_DDL
name = f"pgwtest_lp_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10)
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(f"SET search_path = {name}")
await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
sep = "&" if "?" in low else "?"
yield f"{low}{sep}options=-csearch_path%3D{name}", name
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
sandbox = await pg_sandbox(ddl=PG_DDL, role="grantee")
return sandbox.dsn, sandbox.schema
class TestLeastPrivilegeDeployment:
@@ -549,26 +531,28 @@ class TestLeastPrivilegeDeployment:
low_dsn, schema = least_privilege_dsn
recorder = _recorder(low_dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("lp1"))
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
await _record_minimal(recorder, call_id="lp1")
await _record_minimal(recorder, call_id="lp2", cost=1.5)
assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级
rows = await _fetch(
low_dsn,
"SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
f"{_RUN_PREFIX}-lp%",
"SELECT call_id, cost FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["lp1", "lp2"],
)
assert [(r["call_id"], r["cost"]) for r in rows] == [
(_cid("lp1"), None),
(_cid("lp2"), 1.5),
("lp1", None),
("lp2", 1.5),
]
assert schema # teardown 会连表带角色删净
finally:
await recorder.aclose()
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_PRE_TENANT_DDL = """
CREATE TABLE {schema}.llm_calls (
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
@@ -595,17 +579,23 @@ CREATE TABLE {schema}.llm_calls (
)
"""
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故这行历史数据的 call_id 直接内联成
# 字面量('old' 是本文件固定的测试常量,不是外部输入)。
_PRE_TENANT_INSERT = (
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
"VALUES ('old', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
)
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的四列
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")]
# 去掉后的顺序与 DDL 逐字一致(这四列在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [
c
for c in _EXPECTED_COLUMNS
if c not in ("tenant_id", "meta", "thinking_observation", "reasoning_effort")
]
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
@@ -632,82 +622,36 @@ async def captured_warnings():
@pytest.fixture
async def pre_tenant_schema(dsn):
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
async def pre_tenant_schema(pg_sandbox) -> tuple[str, str]:
"""一次性沙箱里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
绝不碰共享 public.llm_calls本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转schema 名带 uuid,可重复运行
共享 `llm_calls` 一个字节都不碰本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转
"""
import asyncpg
name = f"pgwtest_pre_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, extra=(_PRE_TENANT_INSERT,))
return sandbox.dsn, sandbox.schema
@pytest.fixture
async def fresh_schema(dsn):
async def fresh_schema(pg_sandbox) -> tuple[str, str]:
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
import asyncpg
name = f"pgwtest_new_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
sandbox = await pg_sandbox()
return sandbox.dsn, sandbox.schema
@pytest.fixture
async def least_privilege_pre_tenant_dsn(dsn):
async def least_privilege_pre_tenant_dsn(pg_sandbox) -> str:
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
`least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)
"""
import asyncpg
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10)
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
yield _search_path_dsn(low, name)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
`role="grantee"` 正是这个现场: 表由 admin 建好(属主不是应用账号),角色只拿到
表级 SELECT/INSERT
"""
sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, role="grantee")
return sandbox.dsn
class TestCallerDimensionsAcceptance:
@@ -719,7 +663,7 @@ class TestCallerDimensionsAcceptance:
recorder = _recorder(fresh_dsn, auto_migrate=True)
try:
await _record_minimal(
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
recorder, call_id="dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
)
cols = await _fetch(
fresh_dsn,
@@ -731,7 +675,7 @@ class TestCallerDimensionsAcceptance:
rows = await _fetch(
fresh_dsn,
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
_cid("dim"),
"dim",
)
assert rows[0]["tenant_id"] == "tenant-a"
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
@@ -752,28 +696,26 @@ class TestCallerDimensionsAcceptance:
schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=True)
try:
await _record_minimal(
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
)
await _record_minimal(recorder, call_id="new", tenant_id="tenant-a", meta='{"k": 1}')
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
# 22 → 26 个 recorder 字段(加 created_at 共 27 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
schema_dsn,
"SELECT call_id, tenant_id, meta FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("new"), _cid("old")],
["new", "old"],
)
by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id[_cid("old")]["meta"]) == {}
assert by_id["new"]["tenant_id"] == "tenant-a"
assert json.loads(by_id["new"]["meta"]) == {"k": 1}
assert by_id["old"]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id["old"]["meta"]) == {}
finally:
await recorder.aclose()
@@ -804,7 +746,7 @@ class TestCallerDimensionsAcceptance:
"""
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
await _record_minimal(recorder, call_id="lpp1") # 不得抛
assert recorder.telemetry_status.degraded is False
assert any("补列失败" in m for m in captured_warnings)
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
@@ -816,8 +758,9 @@ class TestCallerDimensionsAcceptance:
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_PARTITIONED_DDL = """
CREATE TABLE {schema}.llm_calls (
CREATE TABLE llm_calls (
call_id TEXT NOT NULL,
parent_call_id TEXT,
session_id TEXT,
@@ -842,14 +785,14 @@ CREATE TABLE {schema}.llm_calls (
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb,
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (call_id, created_at)
) PARTITION BY RANGE (created_at)
"""
# 仍带 `.format`,但只为月份边界——表名两处都已是裸名,由 search_path 定位
_PARTITION_DDL = (
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls "
"FOR VALUES FROM ('{start}') TO ('{end}')"
"CREATE TABLE llm_calls_current PARTITION OF llm_calls FOR VALUES FROM ('{start}') TO ('{end}')"
)
@@ -863,29 +806,18 @@ def _current_month_bounds() -> tuple[str, str]:
@pytest.fixture
async def partitioned_schema(dsn):
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
async def partitioned_schema(pg_sandbox) -> tuple[str, str]:
"""一次性沙箱里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
legacy_schema 同款隔离: 绝不碰共享 public.llm_calls,teardown DROP
自己建的 schema(CASCADE 连分区一并删)
`legacy_schema` 同款隔离: 共享 `llm_calls` 一个字节都不碰,工厂的
`DROP SCHEMA ... CASCADE` 连分区子表一并删
"""
import asyncpg
name = f"pgwtest_part_{uuid4().hex[:8]}"
start, end = _current_month_bounds()
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PARTITIONED_DDL.format(schema=name))
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
sandbox = await pg_sandbox(
ddl=_PARTITIONED_DDL,
extra=(_PARTITION_DDL.format(start=start, end=end),),
)
return sandbox.dsn, sandbox.schema
class TestConflictTargetFreeInsert:
@@ -900,11 +832,11 @@ class TestConflictTargetFreeInsert:
fresh_dsn, _ = fresh_schema
recorder = _recorder(fresh_dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("nodup"))
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
await _record_minimal(recorder, call_id="nodup")
await _record_minimal(recorder, call_id="nodup", response="second")
assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch(
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup")
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "nodup"
)
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
finally:
@@ -921,14 +853,14 @@ class TestConflictTargetFreeInsert:
part_dsn, _ = partitioned_schema
recorder = _recorder(part_dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
await _record_minimal(recorder, call_id="part", tenant_id="tenant-p")
assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch(
part_dsn,
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
_cid("part"),
"part",
)
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")]
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [("part", "tenant-p")]
finally:
await recorder.aclose()
@@ -944,16 +876,16 @@ class TestManualSchemaModeAcceptance:
async def test_manual_leaves_the_stale_table_untouched(
self, pre_tenant_schema, captured_warnings
):
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的维度静默不写。
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的维度静默不写。
`test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 25 之别
同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 27 之别
"""
schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=False)
try:
recorded = await _record_minimal(
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
recorder, call_id="man", tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch(
schema_dsn,
@@ -966,7 +898,7 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch(
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man")
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", "man"
)
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
@@ -976,7 +908,11 @@ class TestManualSchemaModeAcceptance:
assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
# 逐字钉住四个维度: 前缀断言会让将来漏进告警的新列照样绿
assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
finally:
await recorder.aclose()
@@ -993,16 +929,20 @@ class TestManualSchemaModeAcceptance:
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
try:
recorded = await _record_minimal(
recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}'
recorder, call_id="manlp1", tenant_id="tenant-b", meta='{"k": 2}'
)
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5)
await _record_minimal(recorder, call_id="manlp2", cost=2.5)
assert [m for m in captured_warnings if "补列失败" in m] == []
assert [m for m in captured_warnings if "写入失败" in m] == []
assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
# 逐字钉住四个维度: 前缀断言会让将来漏进告警的新列照样绿
assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
@@ -1024,10 +964,10 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch(
least_privilege_pre_tenant_dsn,
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
f"{_RUN_PREFIX}-manlp%",
f"SELECT {names} FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["manlp1", "manlp2"],
)
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")]
assert [r["call_id"] for r in rows] == ["manlp1", "manlp2"]
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
assert rows[1]["cost"] == 2.5
finally:
@@ -1060,7 +1000,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
# 物理列 = 26 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS
@@ -1177,14 +1117,20 @@ async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -
@pytest.fixture
async def production_template(dsn):
async def production_template(template_admin_dsn):
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
隔离纪律(M4 事故教训) `least_privilege_dsn`: 共享的 `public.llm_calls`
**有意不收敛到 `pg_sandbox`**(设计 §7.1 末段): 它要建三个角色 README
解析出的整套模板 SQL按月建分区,权限语义与失败期清理都是它自己的,工厂
强行接管会把这些语义压扁故它是本文件唯一仍持管理连接的 fixture
隔离纪律(M4 事故教训) `least_privilege_dsn`: 共享表 `llm_calls`
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净
"""
import asyncpg
dsn = template_admin_dsn
suffix = uuid4().hex[:8]
schema = f"pgwtpl_{suffix}"
roles = {
@@ -1199,7 +1145,7 @@ async def production_template(dsn):
f"README 的模板锚点与预期不符: {list(blocks)}"
)
seeded = (_cid("tpl-a"), _cid("tpl-b"))
seeded = ("tpl-a", "tpl-b")
admin_dsn = _search_path_dsn(dsn, schema)
admin = await asyncpg.connect(dsn, timeout=10)
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
@@ -1221,9 +1167,9 @@ async def production_template(dsn):
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
except BaseException:
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
# `TestSchema` 那条 table_name 查 information_schema 的用例不带
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,而三个
# 角色是**全局**对象,不随库消失。`TestSchema` 那条用例如今自带 table_schema
# 过滤已不再受残留影响,但残留本身仍是这个共享实例上的垃圾,该清还是要清。
await admin.close()
await _drop_template_objects(dsn, schema, roles)
raise
@@ -1271,6 +1217,19 @@ class TestProductionTemplate:
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
# 再钉死列的**同源性**: `table` 块必须靠 `LIKE llm_calls_seed` 从库自建的表派生
# 列,绝不能手抄一份列定义。手抄的那份会与 telemetry/schema.py 各自漂移,而漂移
# 的表现是照模板部署的下游少掉新增列——manual 档下库按现有列裁剪写入,那一列
# 就此静默消失,正是可观测性 issue 要消灭的那类静默。
table_sql = blocks["table"]
assert "LIKE llm_calls_seed" in table_sql, "生产模板的列必须由 LIKE 派生,不得手抄"
inlined = [
column
for column in COLUMNS
if re.search(rf"^\s*{column}\s+[A-Z]", table_sql, re.MULTILINE)
]
assert not inlined, f"生产模板内联了列定义 {inlined},与 telemetry/schema.py 必然漂移"
async def test_app_can_insert_but_cannot_mutate(self, production_template):
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
@@ -1283,17 +1242,17 @@ class TestProductionTemplate:
env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10)
try:
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
await conn.execute(_TEMPLATE_INSERT, "tpl-app", "tenant-a")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", "tpl-app")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("UPDATE llm_calls SET response = 'x'")
finally:
await conn.close()
rows = await _fetch(
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", "tpl-app"
)
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
assert [r["call_id"] for r in rows] == ["tpl-app"] # 写入真落库了
async def test_report_can_read_but_cannot_write(self, production_template):
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
@@ -1303,7 +1262,7 @@ class TestProductionTemplate:
conn = await asyncpg.connect(env.report_dsn, timeout=10)
try:
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
await conn.execute(_TEMPLATE_INSERT, "tpl-rpt", "tenant-a")
async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
@@ -73,7 +73,7 @@ class ScriptedTransport:
self.hang = hang
self.calls: list[str] = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append(source.name)
if self.hang:
await asyncio.Event().wait()
+209 -106
View File
@@ -1,11 +1,12 @@
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
DSN .env `PGW_TELEMETRY_PG_DSN`,缺则 skip
隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
与真实批跑共用**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
一个**会删数据的脚本**一律在自建的临时 schema 里操作(DSN search_path),
teardown `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
的行数前后不变,"search_path 没生效"这种最坏情况钉成红灯而不是静默删库
这条纪律取代了此前那条"跑完对比共享表行数"的安全网行数快照守的是安全属性,却把它
编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴
权限边界两个方向都没有
"""
from __future__ import annotations
@@ -16,10 +17,8 @@ import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway.telemetry.schema import PG_DDL
@@ -55,20 +54,6 @@ def _partitioned_ddl() -> str:
)
def _dsn_value() -> str | None:
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _stamp(delta: timedelta) -> datetime:
return datetime.now(UTC) + delta
@@ -84,43 +69,6 @@ def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedP
)
@pytest.fixture
async def dsn():
value = _dsn_value()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
import asyncpg
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(f"SET search_path = {name}")
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
return name
async def _drop_schema(dsn_value: str, name: str) -> None:
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
import asyncpg
@@ -142,50 +90,31 @@ async def _call_ids(schema_dsn: str) -> list[str]:
return [r["call_id"] for r in rows]
async def _public_count(dsn_value: str) -> int:
"""共享表的行数;本测试全程不得让它变动一行。"""
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
return -1
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
finally:
await conn.close()
@pytest.fixture
async def partitioned_schema(dsn):
async def partitioned_sandbox(pg_sandbox):
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
name = await _make_schema(
dsn,
"part",
_partitioned_ddl(),
return await pg_sandbox(
ddl=_partitioned_ddl(),
extra=(
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
),
role="owner",
)
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
@pytest.fixture
async def plain_schema(dsn):
async def plain_sandbox(pg_sandbox):
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
name = await _make_schema(dsn, "plain", PG_DDL)
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
return await pg_sandbox(ddl=PG_DDL, role="owner")
class TestPartitionedTarget:
async def test_partitioned_table_exits_three_without_deleting_anything(
self, partitioned_schema
self, partitioned_sandbox
):
schema_dsn, schema = partitioned_schema
await _seed(
schema_dsn,
partitioned_sandbox.dsn,
[
("part-old-1", "", _stamp(timedelta(days=-30))),
("part-old-2", "acme", _stamp(timedelta(days=-20))),
@@ -194,24 +123,28 @@ class TestPartitionedTarget:
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
result = _run(
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
"--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
assert f"{schema}.llm_calls" in result.stdout
assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout
class TestPlainTableBatches:
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
schema_dsn, schema = plain_schema
before_public = await _public_count(dsn)
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
await _seed(
schema_dsn,
plain_sandbox.dsn,
[
("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))),
@@ -227,7 +160,7 @@ class TestPlainTableBatches:
"--backend",
"postgres",
"--dsn",
schema_dsn,
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
@@ -236,8 +169,8 @@ class TestPlainTableBatches:
)
assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
assert f"{schema}.llm_calls" in result.stdout
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
@@ -245,30 +178,200 @@ class TestPlainTableBatches:
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
assert await _public_count(dsn) == before_public
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
schema_dsn, _ = plain_schema
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "将删除行数: 1" in result.stdout
assert "dry-run" in result.stdout
assert await _call_ids(schema_dsn) == ["old-1"]
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestExplicitTable:
"""`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。
单测只能验参数分类,验不了 `to_regclass` 的语义schema 不存在返 NULL 而非抛错
引号限定名区分大小写无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立
"""
async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
await _seed(
plain_sandbox.dsn,
[
("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))),
("old-3", "acme", _stamp(timedelta(days=-20))),
("old-4", "acme", _stamp(timedelta(days=-15))),
("old-5", "", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--batch-size",
"2",
"--table",
f"{plain_sandbox.schema}.llm_calls",
)
# 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout
assert "批次 1" in result.stdout
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
"""schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用""""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
missing = "pgw_s_nosuchxxxxxxxx"
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{missing}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert missing in result.stderr
assert "llm_calls" in result.stderr
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
"""拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
sandbox_a.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{sandbox_b.schema}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert await _call_ids(sandbox_a.dsn) == ["a-old"]
assert await _call_ids(sandbox_b.dsn) == ["b-old"]
async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{partitioned_sandbox.schema}.llm_calls",
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]
class TestInferredTargetHint:
async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
"""未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。
该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 0, (result.stdout, result.stderr)
assert "search_path" in result.stdout
assert "--table" in result.stdout
async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
"""dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "--table" not in result.stdout
class TestSearchPathFallsThrough:
async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
"""最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。
用沙箱角色的**** DSN (search_path 回落 `"$user", public`,而角色名与 schema
名有意错开, `"$user"` 命不中沙箱),不给 `--table`, `--apply`角色对共享表
无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
没有则解析不到**不断言 PG 的英文原文**服务端 `lc_messages` 不由测试掌握
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.bare_dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert result.stderr.strip()
assert "llm_calls" in result.stderr
# 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestMissingAsyncpg:
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
场景: 脚本跑在子进程里,monkeypatch 对它无效DSN **真实可连**的临时 schema,
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿
"""
schema_dsn, _ = plain_schema
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
stub = tmp_path / "stub"
stub.mkdir()
(stub / "asyncpg.py").write_text(
@@ -285,7 +388,7 @@ class TestMissingAsyncpg:
"--backend",
"postgres",
"--dsn",
schema_dsn,
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
@@ -295,4 +398,4 @@ class TestMissingAsyncpg:
assert result.returncode == 2, (result.stdout, result.stderr)
assert "asyncpg" in result.stderr
assert "pip install" in result.stderr
assert await _call_ids(schema_dsn) == ["old-1"]
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
+1 -1
View File
@@ -210,7 +210,7 @@ class ClockAdvancingTransport:
self.clock = clock
self.calls = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
advance, action = self.script.pop(0)
self.clock.advance(advance)
+217 -1
View File
@@ -5,12 +5,19 @@ import hashlib
import json
import pytest
from loguru import logger
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.errors import ResultInvalidError, TransientError
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
SourceConfig,
ThinkingObservation,
)
_MSGS = [{"role": "user", "content": "hi"}]
@@ -113,6 +120,62 @@ class TestKeyFormula:
"m", messages2, "p", None
)
def test_request_tier_changes_key(self):
"""同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。
请求级档位必须**独立于** `model_fingerprint` key: 后者是装配期算出的
集合级指纹,一次调用改档位不会让它变一个字节
"""
k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW)
k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX)
assert k_low != k_max
def test_explicit_none_tier_is_not_the_absent_tier(self):
"""`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。
二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应,
而后者的内容恰恰是缺推理过程的
"""
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
"m", _MSGS, "proj", None, reasoning_effort=Effort.NONE
)
def test_absent_tier_keeps_legacy_key(self):
"""不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。
golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加
`reasoning_effort` 维度之前的实现,不得随实现漂移
"""
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
None,
reasoning_effort=None,
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
def test_declared_tier_key_is_a_golden(self):
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
存量(不表态)那侧的 golden `test_absent_tier_keeps_legacy_key` 守着,
"档位怎么写进 key"此前没有任何字面量断言变异实测把 `str(...)` 换成
`repr(...)`,全套件依然全绿(2026-09-05 独立验证查出)
"""
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
None,
reasoning_effort=Effort.LOW,
) == ("pgw:cache:21d7be93729635b27d4ee54e0e7e7310554bb04faf08919f8ce83794ac33f575")
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
"s1",
reasoning_effort=Effort.NONE,
) == ("pgw:cache:44f4f1ce1ee39e5003a27f4f21a531554cb54d1c66e936d11008d4ff06ddc5e6")
class _Terminal:
def __init__(self, response):
@@ -162,6 +225,21 @@ class TestCacheFlow:
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
assert third.cache_hit is True and terminal.calls == 2
async def test_differing_reasoning_effort_does_not_hit(self):
"""接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。
只测 `build_cache_key` 不够参数加了却没人传是本改动最可能的落地方式,
那种缺口在公式层的用例里完全看不见
"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp())
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal)
assert terminal.calls == 2 # 两档各自回源
third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
assert third.cache_hit is True and terminal.calls == 2 # 同档才命中
async def test_structured_injection_does_not_pollute_key(self):
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
backend = InMemoryCache()
@@ -249,6 +327,143 @@ class TestObservabilityFieldsOnHit:
assert hit.cached_prompt_tokens is None and hit.model_reported is None
class TestThinkingObservationRehydration:
"""issue #16/#17: 命中回放必须复活成枚举实例,而不是 JSON 里的裸 str。
str 与字段注解分叉,下游拿 `resp.thinking_observation is
ThinkingObservation.OBSERVED` 判等会在缓存命中路径上静默为 False
"""
async def test_hit_replays_enum_instance_not_bare_str(self):
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(thinking_observation=ThinkingObservation.OBSERVED))
await mw(ChatRequest(messages=_MSGS), terminal)
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and terminal.calls == 1
assert isinstance(hit.thinking_observation, ThinkingObservation)
assert hit.thinking_observation is ThinkingObservation.OBSERVED
async def test_unknown_value_degrades_to_unknown_and_still_hits(self):
"""域外取值降级为 UNKNOWN,内容照常复活——不得因此作废整条缓存。
真实场景: 三项目共用一个 Redis,先升级的项目写入了本版没有的第四态,
未升级的两个项目若把它判成未命中,就会在这些 key 上每次真打网关随后
覆写回旧值,两个版本互相打对方的缓存(表现是命中率莫名腰斩)一个纯
可观测性字段不该有能力废掉内容完好的缓存响应
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
poisoned["thinking_observation"] = "partially_observed"
poisoned.pop("structured_data", None)
await backend.set(key, json.dumps(poisoned), 3600)
terminal = _Terminal(_resp())
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
resp = await mw(ChatRequest(messages=_MSGS), terminal)
finally:
logger.remove(sink_id)
assert terminal.calls == 0 and resp.cache_hit is True
assert resp.content == "from-a-newer-version" # 内容完好,照常复活
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
# 单独一条讲清原因的 warning: 通用的"重建失败"没有任何线索指向真因
hits = [m for m in messages if "partially_observed" in m]
assert len(hits) == 1, f"域外取值必须单独告警一次,实得 {len(hits)} 条: {messages}"
assert "thinking_observation" in hits[0]
assert [m for m in messages if "重建失败" in m] == []
async def test_a_broken_payload_still_falls_back_to_source(self):
"""对照组: 内容完整性真被破坏时,仍必须按未命中回源(降级方向不变)。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
await backend.set(key, "{not json at all", 3600)
terminal = _Terminal(_resp())
resp = await mw(ChatRequest(messages=_MSGS), terminal)
assert terminal.calls == 1 and resp.cache_hit is False
assert resp.content == "cached"
async def test_legacy_entry_without_key_rehydrates_to_default(self):
"""升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
legacy = dataclasses.asdict(_resp(content="legacy"))
legacy.pop("thinking_observation")
legacy.pop("structured_data", None)
await backend.set(key, json.dumps(legacy), 3600)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0
assert hit.thinking_observation is ThinkingObservation.UNKNOWN
class TestAppliedEffortRehydration:
"""issue #20: 实际档同样必须复活成枚举,理由与 `thinking_observation` 逐条相同。
JSON 里存的是 `StrEnum` 的字符串值;不转就复活成裸 str,而库内一路是
`is Effort.LOW` 的身份比较命中路径上会静默判否,且下游拿到的类型与字段
注解分叉缓存是档位的**第三条入口**(另两条是 `.env` 解析与 `chat()` 参数),
归一化不变式必须在这里也闭合
"""
async def test_hit_replays_enum_instance_not_bare_str(self):
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(applied_effort=Effort.LOW))
await mw(ChatRequest(messages=_MSGS), terminal)
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and terminal.calls == 1
assert isinstance(hit.applied_effort, Effort)
assert hit.applied_effort is Effort.LOW
async def test_unknown_tier_degrades_to_none_and_still_hits(self):
"""域外档位降级为 None(=不知道这次跑在哪档),不作废内容完好的条目。
降级方向与 `thinking_observation` 同源: 共用一个 Redis 的项目里,先升级
的那个可能写入本版没有的档位名,未升级的项目若判成未命中,两个版本就会
互相打对方的缓存归因字段不该有能力废掉一条内容完好的响应
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
poisoned["applied_effort"] = "ultra"
poisoned.pop("structured_data", None)
await backend.set(key, json.dumps(poisoned), 3600)
terminal = _Terminal(_resp())
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
resp = await mw(ChatRequest(messages=_MSGS), terminal)
finally:
logger.remove(sink_id)
assert terminal.calls == 0 and resp.cache_hit is True
assert resp.content == "from-a-newer-version"
assert resp.applied_effort is None
hits = [m for m in messages if "ultra" in m]
assert len(hits) == 1, f"域外档位必须单独告警一次,实得 {len(hits)} 条: {messages}"
assert "applied_effort" in hits[0]
assert [m for m in messages if "重建失败" in m] == []
async def test_legacy_entry_without_key_rehydrates_to_none(self):
"""升级前写入的条目没有该键,必须照常复活并落到默认 None。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
legacy = dataclasses.asdict(_resp(content="legacy"))
legacy.pop("applied_effort")
legacy.pop("structured_data", None)
await backend.set(key, json.dumps(legacy), 3600)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0
assert hit.applied_effort is None
class _BrokenBackend:
async def get(self, key):
raise ConnectionError("redis down")
@@ -369,6 +584,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
# 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"])
+297
View File
@@ -12,6 +12,7 @@ from polygateway import (
AllSourcesExhausted,
GatewayClient,
GatewaySettings,
RequestRejectedError,
gather_bounded,
)
from polygateway.backends.memory.breaker import InMemoryGate
@@ -24,6 +25,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
@@ -198,6 +200,246 @@ class TestSamplingOverlay:
assert [c["seed"] for c in captured] == [1, 2]
class TestReasoningEffortPriority:
"""三层优先级: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
一律抓**真实请求体**而非只查 `ChatRequest` 字段: 档位的价值全在发出去的那几个
字节上,只断言中间态会让"字段填了但一路没人读"这种缺口继续通过测试issue #20
`extra_body` 绕行正是这么长出来的
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
def _zhipu(self, **overrides):
# glm-5.3 的档位是 low/high/max(能力表已登记),zhipu 的 wire 三样俱全,
# 是唯一能同时看清"开启形态"与"档位键"的组合
overrides.setdefault("model", "glm-5.3")
return _source(provider="zhipu", **overrides)
async def test_request_effort_wins_over_source(self):
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MAX)
assert captured[0]["reasoning_effort"] == "max"
assert captured[0]["thinking"] == {"type": "enabled"}
async def test_none_request_does_not_clear_source(self):
"""请求级不表态 ≠ 请求级要求"不推理": 前者必须让源级默认继续生效。"""
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "low"
async def test_request_none_tier_is_an_opinion_not_an_absence(self):
"""请求级 `none` 是"要求不推理",不得被当成"没表态"而回落到源级档位。"""
captured = []
source = self._zhipu(model="glm-5.2", reasoning_effort=Effort.MAX)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE)
assert captured[0]["thinking"] == {"type": "disabled"}
assert "reasoning_effort" not in captured[0]
@pytest.mark.parametrize(
("provider", "model", "fragment"),
[
("qwen", "qwen-max", {"enable_thinking": True}),
("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}),
("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}),
("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}),
],
)
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment):
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。
**只覆盖 `on_base` 自己就说全了""的四段**openai/anthropic/google 的开档
旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位那是设计
§4.2 声明过的**有意变更**(medium GLM/kimi/deepseek 的档位表里根本不存在,
是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter
登记均为默认推理,不注入也仍是""minimax 不在此列: 它的模型不满足该前提,
已按 issue #21 改回 medium,由下一条用例单独守。
qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`;
zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态
"""
captured = []
source = _source(provider=provider, model=model, enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
body = captured[0]
assert {k: body[k] for k in fragment} == fragment
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档
assert "reasoning_effort" not in body
async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self):
"""回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。
本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是
"这些模型默认就推理,不注入也仍是''"T10 真实网关实测推翻了该前提:
MiniMax-M3 不带任何推理参数时 5/5 **不推理**(六个强度值则全部生效)
于是存量下游从"真开推理"静默变成"不推理", `resolve_thinking` Phase 5
无条件放行 `auto`能力表也堵不住这条路
断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见
"""
captured = []
source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "medium"
class TestEffortFallbackWiring:
"""源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。
只测配置值域与纯函数是不够的: 变异实测显示把 `fallback=source.effort_fallback`
换成硬编码 `"error"`,全套单测依然全绿`nearest` 是人类明确要求实现的功能,
没有端到端用例它会在零告警下变成死代码(2026-09-05 独立验证查出)
**该形态有两道门,必须各测各的**: transport 那道决定发出去的字节,`_guard_thinking`
那道决定装配能不能过只钉住 transport,装配守卫退化成硬编码 `error` ,配了
`nearest` 的源会在**运行期本来跑得起来**的情况下于装配期当场被判死,而这半边
第一轮修复时正是漏掉的那半边(M22)
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
async def test_nearest_sends_the_mapped_tier(self):
"""glm-5.3 只有 low/high/max: 请求 `medium` 等距,按"取弱"落到 low。"""
captured = []
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM)
assert captured[0]["reasoning_effort"] == "low"
async def test_error_fallback_refuses_the_same_request(self):
"""默认 `error` 必须报错而不是映射: 一次静默的换档就是一笔没打招呼的账单。"""
captured = []
source = _source(provider="zhipu", model="glm-5.3")
async with self._capturing_client(captured, sources=[source]) as client:
with pytest.raises(RequestRejectedError, match="medium"):
await client.chat(
[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
)
assert captured == [] # 请求根本没发出去
def test_nearest_survives_the_assembly_guard(self):
"""装配守卫也必须用源级 `fallback`: 运行期能映射的源不该在装配期被判死。
源级 `medium` + glm-5.3(只有 low/high/max) `nearest` 唯一能被观测到的
装配期形态守卫硬编码 `error` ,这次 `from_env` 会抛
`ThinkingUnsupportedError` 而不是返回 client
"""
env = dict(_ENV)
for key in list(env):
if key.startswith("LLM__QWEN__"):
del env[key]
env.update(
{
"LLM__ZHIPU__1__BASE_URL": "https://gw.example/v1",
"LLM__ZHIPU__1__API_KEY": "sk-a",
"LLM__ZHIPU__1__MODEL": "glm-5.3",
"LLM__ZHIPU__1__TIMEOUT_S": "120",
"LLM__ZHIPU__1__REASONING_EFFORT": "medium",
"LLM__ZHIPU__1__EFFORT_FALLBACK": "nearest",
}
)
client = GatewayClient.from_env("LLM", env=env)
assert isinstance(client, GatewayClient)
class TestAppliedTierReachesTheCaller:
"""`LLMResponse.applied_effort` 报的是**真正发出去的**那一档(计划 T8-5)。
对下游是新能力(它终于能知道这次跑在哪档),对遥测是前置条件: 记请求档会让
按档分组的压测把整行挂在一个从未发出过的档下,而那种数据错得看不出来
"""
async def test_response_carries_the_mapped_tier(self):
"""glm-5.3 无 `medium`: 开了 nearest 后实际跑的是 low,响应必须这么说。"""
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
async with _client(sources=[source]) as client:
resp = await client.chat(
[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
)
assert resp.applied_effort is Effort.LOW
async def test_no_statement_leaves_the_field_none(self):
async with _client() as client:
resp = await client.chat([{"role": "user", "content": "hi"}])
assert resp.applied_effort is None
class TestUnsupportedTierIsRefusedNotRetried:
"""档位不可满足 = 请求本身的问题: 报 `RequestRejectedError`,不重试、不伤熔断。
重试与换源都不会让它变对(设计 §10),而把它计进熔断更糟一次配置错误会
把一个健康的源关掉,拖垮与推理无关的所有调用
"""
async def test_tier_error_never_reaches_the_gateway_or_the_breaker(self):
sent = []
def handler(request):
sent.append(request)
return _sse()
# 阈值取 1: 只要这次失败被计进熔断,门当场开路,断言立刻可见
gate = InMemoryGate(config=BreakerConfig(1, 60.0, 120.0))
source = _source(name="zp", provider="zhipu", model="glm-5.3")
async with _client(sources=[source], handler=handler, breaker=gate) as client:
with pytest.raises(RequestRejectedError, match="无法关闭推理"):
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE)
assert sent == [], "请求根本不该发出去: 档位不可满足在组装期就已判定"
assert (await gate.try_enter("zp", "w")).allowed, "配置错误不得计入熔断失败"
class TestRequestTierNormalization:
"""`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。
两条装配路(工厂 / 构造函数全量注入,CLAUDE.md §4.5) `.env` 路的口径必须
一致后者早已是"解析即归一"不归一的后果不是"少个类型注解"那么轻: 档位
一路要被 `is Effort.NONE` 身份比较,裸字符串会在 transport 的错误路径上抛
`AttributeError`,而它不属错误四分类,会穿透 `except ThinkingUnsupportedError`
RetryMW 的分类捕获,以未分类异常冒出 `chat()`(2026-09-05 独立验证实测)
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
async def test_bare_string_tier_reaches_the_wire(self):
captured = []
source = _source(provider="zhipu", model="glm-5.3")
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="max")
assert captured[0]["reasoning_effort"] == "max"
async def test_illegal_tier_is_a_value_error_listing_the_vocabulary(self):
"""非法档位是调用方编程错误: 当场 `ValueError`,不进洋葱、不成为未分类异常。"""
source = _source(provider="zhipu", model="glm-5.3")
async with _client(sources=[source]) as client:
with pytest.raises(ValueError) as exc:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="lowest")
message = str(exc.value)
assert "chat(reasoning_effort=...)" in message
assert all(tier.value in message for tier in Effort)
class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
@@ -324,6 +566,61 @@ class TestModelFingerprint:
b = build_model_fingerprint([_source(extra_body={"temperature": 1})])
assert a != b
def test_source_tier_enters_fingerprint(self):
"""源级 `reasoning_effort` 改变请求体,就必须改变缓存身份(与 issue #5 同理)。
本用例同时守着一个易漏点: 只配 `REASONING_EFFORT`既无 `extra_body` 也无
`ENABLE_THINKING` 的源,必须能进入指纹的 marks 集合否则 `_fingerprint_mark`
改了也白改,四个指纹会全部相等
"""
from polygateway.client import build_model_fingerprint
plain = build_model_fingerprint([_source()])
low = build_model_fingerprint([_source(reasoning_effort=Effort.LOW)])
max_ = build_model_fingerprint([_source(reasoning_effort=Effort.MAX)])
off = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
assert len({plain, low, max_, off}) == 4
def test_source_tier_is_distinguished_from_the_thinking_sugar(self):
"""`reasoning_effort=NONE` 与 `enable_thinking=False` 不得摘要成同一个指纹。
两者语义等价但取值不同(`"none"` vs `false`),让它们撞车会把"两种写法"
变成"一种缓存身份",日后任一侧语义微调都会静默复用另一侧的响应
"""
from polygateway.client import build_model_fingerprint
by_tier = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
by_sugar = build_model_fingerprint([_source(enable_thinking=False)])
assert by_tier != by_sugar
def test_absent_tier_fingerprint_is_byte_identical_to_before(self):
"""不表态档位的存量源不得因本次升级平白冷启动: 字面量逐字相同。
两条: 纯净源仍是裸 model 合集;只配 extra_body 的源仍是升级前那个摘要
"""
import hashlib
import json
from polygateway.client import build_model_fingerprint
assert build_model_fingerprint([_source()]) == "qwen-max"
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_declared_tier_fingerprint_is_a_golden(self):
"""配了档位那一侧的指纹字面量也要钉死: 它变了就是该源整段缓存冷启动。
存量(不表态)那侧由 `test_absent_tier_fingerprint_is_byte_identical_to_before`
守着;本条守的是"档位怎么摘要进 mark"字面量硬编码,不在测试里重算
重算等于把实现抄一遍,实现改了两边一起变,断言就白写了
"""
from polygateway.client import build_model_fingerprint
assert build_model_fingerprint([_source(reasoning_effort=Effort.LOW)]) == (
"qwen-max|76f2b3e419e1a727f7f31b6144da0b40d62a00ca93c91ce5901b4ebd48c0b8c0"
)
class TestFactories:
def test_from_env_assembles(self):
+143 -3
View File
@@ -8,6 +8,8 @@ from loguru import logger
from polygateway.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.types import Effort, SourceConfig
_BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
@@ -132,6 +134,113 @@ class TestExtraBodyParsing:
GatewaySettings.from_env("LLM", env=env)
class TestReasoningEffortParsing:
"""源级推理档位两个键的 env 解析(issue #20 Task 4)。"""
def test_effort_key_parsed(self):
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "low"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].reasoning_effort is Effort.LOW
def test_absent_keys_keep_the_source_silent(self):
"""未配置 = 不表态,与 `Effort.NONE`(要求不推理)是两回事;映射默认关闭。"""
src = GatewaySettings.from_env("LLM", env=_env()).sources[0]
assert src.reasoning_effort is None
assert src.effort_fallback == "error"
def test_invalid_effort_lists_vocabulary(self):
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "lowest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "REASONING_EFFORT" in message
assert all(tier.value in message for tier in Effort)
def test_effort_fallback_parsed(self):
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "nearest"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].effort_fallback == "nearest"
def test_effort_key_tolerates_case_and_whitespace(self):
"""`.env` 里的行尾空格与大写写法是常态,档位取值本身没有大小写语义。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": " LOW "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].reasoning_effort is Effort.LOW
def test_effort_fallback_tolerates_case_and_whitespace(self):
"""与相邻的 REASONING_EFFORT 同口径: 同一份 .env 里两个键脾气不同即是坑。
`EFFORT_FALLBACK=Nearest` 此前会原样落到 `SourceConfig`,被值域校验拒掉
而人看着 .env 里明明写了 nearest(2026-09-05 独立验证查出)
"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": " Nearest "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].effort_fallback == "nearest"
def test_invalid_effort_fallback_rejected(self):
"""`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "effort_fallback" in message
assert "nearest" in message and "error" in message
class TestThinkingFlagContradiction:
"""`enable_thinking` 与 `reasoning_effort` 说的是同一件事(设计 §4.2 语法糖)。
矛盾时报错而非后者赢: 两个字段表达同一件事时,矛盾是配置错误,
静默取其一等于替下游猜它想要哪个
"""
def _source(self, enable_thinking, effort):
env = _env(
**{
"LLM__QWEN__1__ENABLE_THINKING": enable_thinking,
"LLM__QWEN__1__REASONING_EFFORT": effort,
}
)
return GatewaySettings.from_env("LLM", env=env).sources[0]
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("true", "none"), ("false", "low"), ("false", "auto"), ("false", "max")],
)
def test_contradictory_thinking_flags_rejected(self, enable_thinking, effort):
with pytest.raises(ValueError) as exc:
self._source(enable_thinking, effort)
message = str(exc.value)
assert "enable_thinking" in message and "reasoning_effort" in message
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("false", "none"), ("true", "auto"), ("true", "low")],
)
def test_consistent_flags_allowed(self, enable_thinking, effort):
"""语义一致就放行: `False`+`none` 与 `True`+某个开启档都只是说了两遍。"""
src = self._source(enable_thinking, effort)
assert src.enable_thinking is (enable_thinking == "true")
assert src.reasoning_effort is Effort(effort)
def test_one_sided_declaration_never_trips_the_guard(self):
"""只配一个键是常态(存量源全是这样),不得被矛盾守卫误伤。"""
assert self._source("true", None).reasoning_effort is None
assert self._source(None, "high").enable_thinking is None
def test_contradiction_guarded_on_direct_construction(self):
"""守卫挂在构造期而非 env 解析处: 构造函数全量注入那条装配路同样过闸。"""
base = SourceConfig(
name="s1",
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk-x",
model="qwen-max",
timeout_s=60.0,
)
with pytest.raises(ValueError, match="reasoning_effort"):
dataclasses.replace(base, enable_thinking=True, reasoning_effort=Effort.NONE)
class TestResilienceKeys:
def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env())
@@ -637,10 +746,41 @@ class TestCrossFieldInvariants:
GatewayClient.from_settings(settings)
def test_unknown_thinking_shape_fails_at_assembly(self):
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
settings = self._thinking_sources("openai", "kimi-k3", False)
# 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发
# OpenAI 标准的 reasoning_effort),故样本改为显式注册一个未知段——守卫测的
# 是机制,不是某个段当时的配置
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
settings = self._thinking_sources("mystery", "kimi-k3", False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(settings)
GatewayClient.from_settings(settings, registry=register_provider(mystery))
def test_source_tier_the_model_cannot_satisfy_fails_at_assembly(self):
"""守卫必须读**源级档位**,而不只是 `enable_thinking`(设计 §4.2 三层优先级)。
变异实测: `_guard_thinking` `source_effort=source.reasoning_effort`
改成 `None`,全套单测依然全绿(2026-09-05 独立验证查出)文案里必须出现
可执行替代 `low`glm-5.3 官方关不掉推理,只报"不行"会把人推回
`extra_body` 那条绕过治理的老路(issue #20 的成因)。
"""
base = self._base()
src = dataclasses.replace(
base.sources[0], provider="zhipu", model="glm-5.3", reasoning_effort=Effort.NONE
)
with pytest.raises(ValueError) as exc:
GatewayClient.from_settings(dataclasses.replace(base, sources=(src,)))
message = str(exc.value)
assert "glm-5.3" in message
assert "low" in message
def test_openai_segment_now_assembles_with_the_standard_field(self):
# 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言,
# 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知
settings = self._thinking_sources("openai", "gpt-5.5", False)
assert GatewayClient.from_settings(settings) is not None
def test_supported_combination_assembles(self):
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
+247 -11
View File
@@ -16,13 +16,14 @@ from polygateway.errors import (
)
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.transports._http_errors import summarize_body
from polygateway.transports.openai_compat import (
OpenAICompatTransport,
_iter_sse_deltas,
_sse_data_payload,
)
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
def _source(**overrides):
@@ -60,20 +61,22 @@ def _sse_stream(*frames, done=True):
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
def _transport_for(handler):
def _transport_for(handler, *, registry=None):
mock = httpx.MockTransport(handler)
return OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock)
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock),
registry=registry,
)
async def _complete(transport, source, *, stream=True, overlay=None):
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None):
return await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=source,
stream=stream,
overlay=overlay or {},
call_id="cid-1",
reasoning_effort=reasoning_effort,
)
@@ -120,6 +123,7 @@ async def _recorded_cost(result, source):
latency_ms=1,
response=response,
error=None,
reasoning_applies=True,
)
return recorder.rows[0]["cost"]
@@ -471,6 +475,216 @@ class TestReasoningTokens:
assert result.reasoning_tokens is None
class TestThinkingObservationVerdict:
"""issue #16/#17: 两条组装路径都必须裁定"推理到底发生没发生"
流式与非流式各测一遍是刻意的只填一条路径正是本 issue 的根因形态:
库在其中一条路径上悄悄给出了不同的可观测性,下游无从分辨
"""
def _reasoning_usage(self, reasoning):
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
async def test_stream_reasoning_content_is_observed(self):
def handler(request):
return _sse_stream(
_chunk(reasoning="想一下"), _chunk(content="ok"), _chunk(usage=_USAGE)
)
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.OBSERVED
async def test_stream_without_any_signal_is_unknown(self):
"""无正文、无 details: 库不知道,就如实说不知道。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.UNKNOWN
async def test_stream_zero_reasoning_tokens_is_absent(self):
"""上游明确上报 0 才算 ABSENT——这是唯一的"确实没推理"证据。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.ABSENT
async def test_non_stream_reasoning_content_is_observed(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42", "reasoning_content": "想一下"}}],
"usage": _USAGE,
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.OBSERVED
async def test_non_stream_without_any_signal_is_unknown(self):
"""M3 非流式实测形态: 推理已计费却既不回传正文也不回传 details。"""
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.UNKNOWN
async def test_non_stream_zero_reasoning_tokens_is_absent(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": self._reasoning_usage(0),
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.ABSENT
class TestThinkingReconciliation:
"""对账告警按 (source, model, direction) 节流(设计 §5)。
键的三段缺一不可,理由同源: 合并任意一段,都会让先出现的那一组把另一组
永久静音同一模型的开/关两档是两个独立的矛盾,同一模型的两个源背后是
两个独立的账号/网关
"""
def _handler(self, request):
payload = json.loads(request.content)
if payload.get("reasoning_effort") == "none":
# 关闭档却回了推理正文 → OBSERVED,与"要求关闭"矛盾
return _sse_stream(
_chunk(reasoning="偷偷想了"), _chunk(content="ok"), _chunk(usage=_USAGE)
)
# 开启档却零信号 → UNKNOWN,无法确认是否生效(M3 实测形态)
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
def _minimax(self, enable_thinking, name="mm"):
return _source(
name=name, provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
async def test_same_model_and_direction_warns_only_once(self):
transport = _transport_for(self._handler)
source = self._minimax(False)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, source)
await _complete(transport, source)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 1, f"同一 (model, direction) 应只告警一次,实得 {len(hits)}"
async def test_each_source_gets_its_own_warning(self):
"""多源多账号是本库的核心场景: 同一 model 跨 N 个源不得只喊第一个。
节流键漏掉源标识时,5 个共用同一模型的源里第一个出问题的喊完一次,其余
四个**永久静音**而每个源背后是独立的账号/网关,它们的行为互不代表
"""
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False, name="gw-a"))
await _complete(transport, self._minimax(False, name="gw-b"))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 2, f"两个源各应告警一次,实得 {len(hits)}"
async def test_the_warning_names_the_source(self):
"""拿到告警的人得知道该查哪个网关: 只报模型名定位不到源。"""
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False, name="gw-a"))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 1
assert "gw-a" in hits[0], f"告警未点名出问题的源: {hits[0]}"
async def test_switching_direction_earns_a_second_warning(self):
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(True))
await _complete(transport, self._minimax(True))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)}"
async def test_each_tier_of_one_model_earns_its_own_warning(self):
"""同一源同一模型的两个强度档是**两个独立的矛盾**,不得共用一个节流键。
节流键沿用旧的 `enable_thinking` 三态时,两次请求的键逐字相同(都是
`None`档位根本不经过那个字段),于是 `max` 档的矛盾被 `low` 档那次
永久静音档位化后 low max 各喊一次,重复的 low 仍只喊一次
"""
transport = _transport_for(self._zero_signal)
source = _source(name="zp", provider="zhipu", model="glm-5.3")
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, source, reasoning_effort=Effort.LOW)
await _complete(transport, source, reasoning_effort=Effort.LOW)
await _complete(transport, source, reasoning_effort=Effort.MAX)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "glm-5.3" in m]
assert len(hits) == 2, f"low 与 max 应各告警一次,实得 {len(hits)}"
def _zero_signal(self, request):
"""零推理信号的成功响应 → UNKNOWN,与"要求开启"矛盾(M3 实测形态)。"""
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
class TestAppliedTierLeavesTheTransport:
"""本次**实际**发出去的档必须随 TransportResult 上浮(设计 §4.1 / 计划 T8-5)。
不上浮就只能由遥测自己再算一遍请求档, `nearest` 映射后两者不同压测
要按档分组的那一列会挂在一个从未真正发出过的档下,且错得看不出来
"""
def _ok(self, request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
async def test_result_carries_the_mapped_tier_not_the_requested_one(self):
"""glm-5.3 只有 low/high/max: 请求 `medium`,实际发出的是 `low`。"""
transport = _transport_for(self._ok)
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
result = await _complete(transport, source, reasoning_effort=Effort.MEDIUM)
assert result.applied_effort is Effort.LOW
async def test_result_carries_the_tier_that_was_asked_for_when_supported(self):
transport = _transport_for(self._ok)
source = _source(provider="zhipu", model="glm-5.3")
result = await _complete(transport, source, reasoning_effort=Effort.MAX)
assert result.applied_effort is Effort.MAX
async def test_no_statement_stays_none(self):
"""不表态时库既不注入也不推定模型默认档——"没看见"不许说成"发生了""""
transport = _transport_for(self._ok)
result = await _complete(transport, _source())
assert result.applied_effort is None
class TestNonStreamFastPath:
async def test_non_stream_parses_message(self):
def handler(request):
@@ -510,6 +724,10 @@ class TestRequestShaping:
@pytest.mark.parametrize(
("enable_thinking", "expected"),
# 本条断言反复过一次,记下原委以免第三次改回去:
# T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入;
# T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理,
# 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束)
[(True, "medium"), (False, "none")],
)
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
@@ -524,11 +742,20 @@ class TestRequestShaping:
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
await _complete(_transport_for(handler), source)
if expected is None:
assert "reasoning_effort" not in seen
else:
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,两行不可调换。"""
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。
固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile
**真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁不挑 minimax 是因为
它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计),
覆盖发生后看不见"profile 独有的那半边仍在",判据少一半
"""
seen = {}
def handler(request):
@@ -536,14 +763,17 @@ class TestRequestShaping:
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm",
provider="minimax",
model="MiniMax-M3",
enable_thinking=True,
name="zp",
provider="zhipu",
model="glm-5.3",
reasoning_effort="low",
extra_body={"reasoning_effort": "high"},
)
await _complete(_transport_for(handler), source)
# profile 注入的是 low,extra_body 后写故发出去的是 high;顺序一调换就变 low,
# 即下游写在 extra_body 里的覆盖被库悄悄顶掉(issue #20 的成因形态)
assert seen["reasoning_effort"] == "high"
assert seen["thinking"] == {"type": "enabled"} # profile 独有的那半边仍在
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
@@ -602,9 +832,15 @@ class TestRequestShaping:
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
# 2026-09-04 起默认表 8 段全部有形态,守卫样本改为显式注册的未知段
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
source = _source(name="k3", provider="mystery", model="kimi-k3", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="register_provider"):
await _complete(_transport_for(handler), source)
await _complete(_transport_for(handler, registry=register_provider(mystery)), source)
async def test_overlay_merged_into_payload(self):
seen = {}
+39
View File
@@ -49,3 +49,42 @@ def test_telemetry_status_exported():
assert "TelemetryStatus" in polygateway.__all__
assert polygateway.TelemetryStatus is not None
assert "TelemetryStatusProvider" not in polygateway.__all__
def test_thinking_public_surface_exported():
"""issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。
搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类
深路径 import给下游一个稳定引用点,是以后再重组不再破坏下游的前提本库
的约定是顶层导出即公共 API
`observe_thinking` / `reconcile_thinking` ****导出: 它们是 transport 内部
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
永久承诺`ThinkingResolution` ****导出它是已导出的 `resolve_thinking`
的返回类型,不导出等于下游拿得到实例却写不出类型标注
"""
for name in (
"ThinkingCapability",
"ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError",
"ThinkingWire",
"get_capability",
"register_capability",
"resolve_thinking",
):
assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name
assert "observe_thinking" not in polygateway.__all__
assert "reconcile_thinking" not in polygateway.__all__
def test_every_promised_export_is_actually_importable():
"""`__all__` 里的每个名字都必须真的绑在包上。
只维护 `__all__` 而漏掉 import,`from polygateway import X` `import *`
都会当场炸,而逐个点名的用例只覆盖它当时想到的符号2026-09-04
`ThinkingWire` 正是这样漏进来的( `__all__` 里躺了一个提交却 import 不到)
"""
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
+35 -2
View File
@@ -1,15 +1,18 @@
"""ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
import inspect
from typing import Any
import pytest
from polygateway.ports import (
CacheBackend,
EmbeddingTransport,
GateDecision,
GateState,
GateUpdate,
Middleware,
OcrTransport,
Permit,
ProviderGate,
RateLimiter,
@@ -69,7 +72,7 @@ class _DummyMw:
class _DummyTransport:
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
raise NotImplementedError
@@ -142,6 +145,34 @@ def test_protocols_are_runtime_checkable(impl, protocol):
assert isinstance(impl, protocol)
class TestReasoningTierIsOnlyOnTheChatPort:
"""档位属于 chat 端口,且**只属于**它(Task 5b)。
`@runtime_checkable` 只查方法名不查签名,故协议签名本身必须被显式断言
否则实现漏改一个参数,要到运行期调用才会以 `TypeError` 现形,而那时的现场
离根因已经很远
"""
def test_chat_transport_carries_the_per_call_tier(self):
params = inspect.signature(Transport.complete).parameters
assert "reasoning_effort" in params
# 不给默认值是有意的(与 TelemetryRecorder 同一既有约定): 库外无第三方
# 实现者,写全签名成本为零,而默认值会把"漏传"变成静默的"不表态"
assert params["reasoning_effort"].default is inspect.Parameter.empty
@pytest.mark.parametrize(
("protocol", "method"),
[
(EmbeddingTransport, "embed"),
(OcrTransport, "recognize_text"),
(OcrTransport, "parse_layout"),
],
)
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
"""embedding 与 OCR 没有推理语义,给它们加档位只会静默无效(issue #4 同款决策)。"""
assert "reasoning_effort" not in inspect.signature(getattr(protocol, method)).parameters
class _DummyStatusProvider(_DummyRecorder):
@property
def telemetry_status(self) -> TelemetryStatus:
@@ -244,7 +275,9 @@ class TestTelemetryRecorderSignature:
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params)
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
@pytest.mark.parametrize(
"name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"]
)
def test_caller_dimensions_have_no_default(self, name):
import inspect
+4
View File
@@ -177,6 +177,7 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] == pytest.approx(7.2)
@@ -196,6 +197,7 @@ class TestEmitterCost:
latency_ms=1,
response=None,
error="TransientError: boom",
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
@@ -209,6 +211,7 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(model="mystery"),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
@@ -223,5 +226,6 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
+95 -118
View File
@@ -1,63 +1,118 @@
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。
2026-09-04 profile 存的是 `ThinkingWire`(off / on_base / effort_key)而非两个
固定片段档位型模型(GLM-5.3kimi-k3deepseek-v4)""档需要附一个档位值,
两个固定片段表达不了
"""
import pytest
from loguru import logger
from polygateway.providers import (
DEFAULT_CAPABILITIES,
DEFAULT_PROFILES,
ProviderProfile,
ThinkingCapability,
get_capability,
ThinkingWire,
get_provider,
register_capability,
register_provider,
resolve_thinking,
)
_EXPECTED_SEGMENTS = frozenset(
{"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"}
)
class TestDefaultProfiles:
def test_qwen_profile(self):
p = get_provider("qwen")
assert p.thinking_on == {"enable_thinking": True}
assert p.thinking_off == {"enable_thinking": False}
assert p.strip_think_tags is True
assert p.supports_native_schema is False
def test_all_eight_profiles_registered(self):
"""issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。"""
assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS
def test_deepseek_profile(self):
p = get_provider("deepseek")
assert p.thinking_on == {"thinking": {"type": "enabled"}}
assert p.thinking_off == {"thinking": {"type": "disabled"}}
assert p.strip_think_tags is False
def test_qwen_is_a_switch_with_no_tiers(self):
w = get_provider("qwen").thinking
assert w.on_base == {"enable_thinking": True}
assert w.off == {"enable_thinking": False}
assert w.effort_key is None # 百炼靠 thinking_budget 调深度,不是档位
assert get_provider("qwen").strip_think_tags is True
def test_openai_slots_are_unknown_not_empty(self):
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
def test_deepseek_carries_both_switch_and_tier(self):
w = get_provider("deepseek").thinking
assert w.on_base == {"thinking": {"type": "enabled"}}
assert w.off == {"thinking": {"type": "disabled"}}
assert w.effort_key == "reasoning_effort"
故不能下发任何厂商方言参数None = 形态未知 配了 enable_thinking 即报错,
而不是空字典那种"注入了个寂寞"的静默失效
def test_zhipu_matches_the_vendor_migration_note(self):
"""智谱官方: thinking.type=enabled + reasoning_effort 才是 GLM-5.3 的正确形态。"""
w = get_provider("zhipu").thinking
assert w.on_base == {"thinking": {"type": "enabled"}}
assert w.off == {"thinking": {"type": "disabled"}}
assert w.effort_key == "reasoning_effort"
def test_openai_family_sends_the_standard_field_only(self):
"""gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。
minimax 2026-09-05 起不在本组: 它的形态相同,""这一档被迫带上了一个
档位值( `test_minimax_on_tier_carries_a_tier_value`)
"""
p = get_provider("openai")
assert p.thinking_on is None and p.thinking_off is None
assert p.strip_think_tags is False
for name in ("openai", "anthropic", "google"):
w = get_provider(name).thinking
assert w.on_base == {}, name
assert w.off == {"reasoning_effort": "none"}, name
assert w.effort_key == "reasoning_effort", name
def test_minimax_profile_uses_reasoning_effort(self):
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
p = get_provider("minimax")
assert p.thinking_off == {"reasoning_effort": "none"}
assert p.thinking_on == {"reasoning_effort": "medium"}
assert p.strip_think_tags is False
def test_minimax_on_tier_carries_a_tier_value(self):
"""issue #21 的权宜之计: minimax 的""必须真写一个档位值,不能是空片段。
断言反复过一次: T2 "这些模型默认就推理"的推定把它改成 `{}`,T10 真实
网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium
"""
w = get_provider("minimax").thinking
assert w.on_base == {"reasoning_effort": "medium"}
assert w.off == {"reasoning_effort": "none"}
assert w.effort_key == "reasoning_effort"
def test_unknown_provider_fails_loudly(self):
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
with pytest.raises(ValueError, match="glm"):
get_provider("glm")
get_provider("glm") # 段名是 zhipu,不是 glm
with pytest.raises(ValueError):
get_provider("qwen2") # 子串相似也不放行
def test_error_lists_every_registered_segment(self):
with pytest.raises(ValueError, match="zhipu"):
get_provider("nope")
class TestWireNoneSemantics:
"""三个 `None` 语义互不重叠(issue #5 的成果,不可退回成"注入了个寂寞")。"""
def test_on_base_none_means_shape_unknown(self):
w = ThinkingWire(off=None, on_base=None, effort_key=None)
assert w.on_base is None
def test_off_none_means_no_off_shape(self):
"""有开启形态但没有关闭形态,与"整个形态未知"是两回事。"""
w = ThinkingWire(off=None, on_base={"x": 1}, effort_key=None)
assert w.on_base is not None and w.off is None
def test_effort_key_none_means_switch_only(self):
"""qwen 是这一档: 能开能关,但没有档位可谈。"""
assert get_provider("qwen").thinking.effort_key is None
def test_empty_on_base_is_not_none(self):
"""`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。
样本 2026-09-05 minimax 换成 openai: minimax `on_base` issue #21
改回带值,不再是空片段;openai 段是现存 `{}` 语义的代表
"""
w = get_provider("openai").thinking
assert w.on_base == {} and w.on_base is not None
class TestPureFunctionRegistration:
def test_register_returns_new_mapping(self):
glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False)
glm = ProviderProfile(
name="glm",
thinking=ThinkingWire(off={}, on_base={}, effort_key=None),
strip_think_tags=False,
)
table = register_provider(glm)
assert get_provider("glm", registry=table) is glm
# 默认表未被污染(无可变全局状态铁律)
@@ -65,93 +120,15 @@ class TestPureFunctionRegistration:
get_provider("glm")
def test_register_on_custom_base_and_override(self):
custom_qwen = ProviderProfile(
name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False
custom = ProviderProfile(
name="qwen",
thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None),
strip_think_tags=False,
)
table = register_provider(custom_qwen, base=DEFAULT_PROFILES)
assert get_provider("qwen", registry=table).thinking_on == {"x": 1}
assert get_provider("qwen").thinking_on == {"enable_thinking": True}
table = register_provider(custom, base=DEFAULT_PROFILES)
assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1}
assert get_provider("qwen").thinking.on_base == {"enable_thinking": True}
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")
+160
View File
@@ -261,6 +261,157 @@ class TestUsageErrors:
assert result.returncode == 1
# --- --table 的参数分类(issue #18 设计 §4.2);真实解析行为在集成层验 ---
def test_sqlite_with_table_exits_one(self, tmp_path):
"""SQLite 库文件即目标,无 schema 概念,故 `--table` 在该分支无歧义可消。"""
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--table",
"some_schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_without_schema_qualifier_exits_one(self):
"""单段等于没声明: 目标仍由 `search_path` 决定,隐式性原样保留,故拒绝。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_empty_segment_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
".llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_quote_in_a_segment_exits_one(self):
"""含引号的复杂标识符不支持: 此时退回不给 `--table` 的路径(见 epilog)。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
'sch"ema.llm_calls',
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_naming_another_table_exits_one(self):
"""表名段锁死: 不加这条,`--table` 会把本脚本扩成"任意同形表删除工具""""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"audit.events",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
# 错误消息要当场把边界说清: 本脚本的作用域到 llm_calls 为止
assert "llm_calls" in result.stderr
class TestTableIdentifierWhitelist:
"""schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。
这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在),
**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人
"""
def test_schema_with_a_space_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"bad schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_schema_with_a_semicolon_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"a;b.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self):
"""收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://127.0.0.1:1/nope",
"--older-than-days",
"7",
"--table",
"pgw_s_a1b2c3.llm_calls",
)
assert result.returncode == 2
class TestHelp:
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
@@ -271,3 +422,12 @@ class TestHelp:
assert "维护角色" in result.stdout
assert "REVOKE" in result.stdout
assert "PARTITION" in result.stdout
def test_help_states_the_table_name_is_fixed(self):
"""`--table` 只有 schema 一段可变,这条边界必须写在运维会读到的地方。"""
result = _run("--help")
assert result.returncode == 0
assert "--table" in result.stdout
assert "只清理" in result.stdout
assert "llm_calls" in result.stdout
+92 -2
View File
@@ -24,9 +24,11 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
ThinkingObservation,
TransportResult,
)
from tests.contracts.conftest import FakeClock
@@ -62,14 +64,21 @@ def _ok(content="ok"):
class FakeTransport:
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)。"""
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位
`reasoning_effort` 刻意**不给默认值**, `Transport` 协议保持逐字一致:
`@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传"
这类缺口伪装成"调用方没表态",而报错现场离根因很远
"""
def __init__(self, script):
self.script = list(script)
self.calls = []
self.efforts = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort)
action = self.script.pop(0)
if isinstance(action, Exception):
raise action
@@ -134,6 +143,37 @@ def _harness(
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
class TestRequestTierReachesTransport:
"""请求级档位必须一路穿过洋葱到达 transport(Task 5b)。
`ChatRequest` 上填了字段而中间件不搬运,"看起来配了、实际没发出去"的静默
失效正是 issue #20 里下游改用 extra_body 绕过治理的成因。
"""
async def test_request_tier_reaches_transport(self):
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.HIGH)
)
assert transport.efforts == [Effort.HIGH]
async def test_absent_tier_is_carried_as_none(self):
"""不表态也要显式传下去: 漏传与"传了 None"在协议上必须区分不开才安全。"""
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(_REQ)
assert transport.efforts == [None]
async def test_tier_is_carried_on_every_retry_attempt(self):
"""换源重试时档位不得在第二次尝试上丢失。"""
mw, _, _, transport, *_ = _harness(
[_src("a"), _src("b")], [TransientError("boom", source_name="a"), _ok()]
)
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.LOW)
)
assert transport.efforts == [Effort.LOW, Effort.LOW]
class TestSuccessPath:
async def test_first_attempt_success_builds_response(self):
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
@@ -225,6 +265,56 @@ class TestObservabilityPassthrough:
assert resp.cached_prompt_tokens is None and resp.model_reported is None
assert resp.reasoning_tokens is None
async def test_thinking_observation_reaches_the_response(self):
"""issue #16/#17: 裁定归 transport,中间件只透传,不得在途中改判。"""
result = TransportResult(
content="ok",
thinking="想一下",
prompt_tokens=10,
completion_tokens=5,
usage_source="measured",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
thinking_observation=ThinkingObservation.OBSERVED,
)
mw, *_ = _harness([_src("a")], [result])
resp = await mw(_REQ)
assert resp.thinking_observation is ThinkingObservation.OBSERVED
async def test_unjudged_transport_result_stays_unknown(self):
"""不裁定的 transport(如 OCR)透传出来仍是 UNKNOWN,不被默认成 ABSENT。"""
mw, *_ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
async def test_applied_tier_reaches_the_response(self):
"""issue #20: 实际发出的档由 transport 裁定,本层只搬运。
搬运这一步漏掉,`LLMResponse.applied_effort` 恒为 None,而遥测正是从这个
字段取"这一行跑在哪档"整列会静默地全是 NULL
"""
result = TransportResult(
content="ok",
thinking="",
prompt_tokens=10,
completion_tokens=5,
usage_source="measured",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
applied_effort=Effort.HIGH,
)
mw, *_ = _harness([_src("a")], [result])
resp = await mw(_REQ)
assert resp.applied_effort is Effort.HIGH
async def test_unstated_tier_stays_none(self):
"""不表态的调用不得被填成某个档: 那等于替调用方声称它做过一个选择。"""
mw, *_ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.applied_effort is None
class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self):
+396 -16
View File
@@ -9,6 +9,7 @@ import subprocess
from pathlib import Path
import pytest
from loguru import logger
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
@@ -24,12 +25,14 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
EmbeddingTransportResult,
GlobalLimits,
LLMResponse,
OcrTextTransportResult,
RetryPolicy,
SourceConfig,
ThinkingObservation,
)
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}], session_id="sess-1")
@@ -60,6 +63,8 @@ _EXPECTED_COLUMNS = [
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
]
@@ -127,6 +132,10 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None,
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -172,16 +181,17 @@ _FROZEN_SQLITE_INSERT = (
"INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, "
"source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, "
"latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, "
"model_reported, sampling, reasoning_tokens, tenant_id, meta) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation, "
"reasoning_effort) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
_FROZEN_PG_INSERT = (
"INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, "
"messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, "
"ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, "
"sampling, reasoning_tokens, tenant_id, meta) "
"sampling, reasoning_tokens, tenant_id, meta, thinking_observation, reasoning_effort) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, "
"$19, $20, $21, $22, $23, $24) "
"$19, $20, $21, $22, $23, $24, $25, $26) "
# 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、
# 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入
"ON CONFLICT DO NOTHING"
@@ -207,7 +217,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 24
assert len(COLUMNS) == 26
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -221,8 +231,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
)
assert PG_BACKFILL[-1] == (
"meta",
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
"reasoning_effort",
"ALTER TABLE llm_calls ADD COLUMN reasoning_effort TEXT",
)
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -270,7 +280,7 @@ class TestSchemaModule:
pg = telemetry_schema_sql("postgres")
lite = telemetry_schema_sql("sqlite")
for script in (pg, lite):
# 24 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
# 26 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
assert "CREATE TABLE IF NOT EXISTS llm_calls" in script
# 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同)
@@ -304,11 +314,11 @@ class TestBackendColumnParity:
assert sqlite.COLUMNS is COLUMNS
assert postgres.COLUMNS is COLUMNS
def test_caller_dimensions_are_appended_last(self):
def test_new_columns_are_appended_last(self):
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-2:] == ("tenant_id", "meta")
assert COLUMNS[-4:] == ("tenant_id", "meta", "thinking_observation", "reasoning_effort")
class TestSQLiteRecorder:
@@ -378,6 +388,49 @@ class TestSQLiteRecorder:
assert rows["r-zero"] == 0 # 上报了且确实没推理
assert rows["r-none"] is None # 本次调用未上报
async def test_thinking_observation_column_round_trips(self, tmp_path):
"""issue #16: 三态裁定结果落库,事后才能按"这次到底推没推理"分组统计。
断言的是裸字符串 `"observed"` 而非枚举: 归一化在 emitter 侧完成
(`_record` `.value`),recorder 拿到的必须已经是 `str``StrEnum`
虽是 `str` 子类,asyncpg 的参数编码对子类不保证接受,而遥测写失败只
降级成一条 warning,PG 那一路会悄无声息地少一列数据
"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="t-obs", thinking_observation="observed")
await _record_minimal(recorder, call_id="t-absent", thinking_observation="absent")
await _record_minimal(recorder, call_id="t-unknown")
recorder.close()
rows = dict(
sqlite3.connect(tmp_path / "t.db")
.execute("SELECT call_id, thinking_observation FROM llm_calls")
.fetchall()
)
assert rows["t-obs"] == "observed"
assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事
assert rows["t-unknown"] == "unknown"
async def test_reasoning_effort_column_round_trips(self, tmp_path):
"""issue #20: 实际档位落库,事后才分得清"这一行跑在哪档"
断言裸串而非枚举,理由与 `thinking_observation` 逐字相同: `StrEnum`
`str` 子类, asyncpg 对子类编码不保证接受,遥测写失败只降级一条 warning
PG 那一路会静默少一列,SQLite 本地全绿也发现不了
"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="e-low", reasoning_effort="low")
await _record_minimal(recorder, call_id="e-max", reasoning_effort="max")
await _record_minimal(recorder, call_id="e-silent")
recorder.close()
rows = dict(
sqlite3.connect(tmp_path / "t.db")
.execute("SELECT call_id, reasoning_effort FROM llm_calls")
.fetchall()
)
assert rows["e-low"] == "low"
assert rows["e-max"] == "max"
assert rows["e-silent"] is None # 不表态是 NULL,与任何一档都分得开
async def test_sampling_column_round_trips(self, tmp_path):
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
@@ -541,7 +594,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列)
assert cols == _EXPECTED_COLUMNS # 22 → 26 个 recorder 字段(+ created_at 共 27 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -583,7 +636,7 @@ class TestSQLiteCallerDimensionsAcceptance:
stale = sqlite3.connect(db)
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
_EXPECTED_COLUMNS[:-2]
_EXPECTED_COLUMNS[:-4]
) # 补列确实没成功,用例不是在只读库上空转
@@ -591,7 +644,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**: 旧表 22 INSERT 字段 + `created_at` = 23,
补齐后 24 + `created_at` = 25混用 INSERT 字段数与物理列数是本处最易错的地方
补齐后 26 + `created_at` = 27混用 INSERT 字段数与物理列数是本处最易错的地方
"""
def _physical_columns(self, db: Path) -> list[str]:
@@ -630,7 +683,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 25"""
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 27"""
db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db)
@@ -639,10 +692,10 @@ class TestSQLiteSchemaMode:
recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 25
assert len(self._physical_columns(db)) == 27
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,25 个物理列齐全(设计 §4.2)。"""
"""manual 只管 ALTER,不管 CREATE: 全新库照建,27 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -866,6 +919,8 @@ class TestPostgresBackfillDiscipline:
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
]
def _recorder(self, conn):
@@ -926,6 +981,7 @@ class TestPostgresTableProbe:
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
]
def _recorder(self, conn):
@@ -1091,6 +1147,7 @@ class TestEmitterRecorderContract:
latency_ms=42,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert set(rec.rows[0]) == set(COLUMNS)
@@ -1108,6 +1165,7 @@ class TestEmitterRecorderContract:
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
)
elif emit == "cache_hit":
await emitter.emit_cache_hit(request=_REQ, response=_resp())
@@ -1118,6 +1176,311 @@ class TestEmitterRecorderContract:
assert set(rec.rows[0]) == set(COLUMNS)
class TestEmitterThinkingObservation:
"""issue #16: 三态裁定经 emitter 落库,且落的是**裸 str** 而非枚举实例。
类型断言不是洁癖: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str`
子类不保证接受,而遥测写失败只降级成一条 warningPG 那一路会静默少一列
数据,本地 SQLite 测试全绿也发现不了归一化因此固定在 emitter ,
`tenant_id`/`meta`/`sampling` 同一先例
"""
async def test_attempt_carries_the_verdict_as_a_plain_string(self):
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation=ThinkingObservation.OBSERVED),
error=None,
reasoning_applies=True,
)
value = rec.rows[0]["thinking_observation"]
assert value == "observed"
assert type(value) is str # 不是 ThinkingObservation: 子类实例不得下沉到 recorder
async def test_a_bare_string_verdict_still_lands(self):
"""下游填裸 str 时**整行**不得丢失(遥测必录)。
`LLMResponse` 是无运行时校验的 frozen dataclass,
`LLMResponse(..., thinking_observation="observed")` 完全自然且 `==` 比较
照常成立; emitter 直接取 `.value`,这里会抛 `AttributeError` 并被
`_record` `except Exception` 吞成一条泛化 warning丢的不是这一列,
是整行,正是 1.3.0 那次"19 次调用一行未落"的同款形态
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation="observed"),
error=None,
reasoning_applies=True,
)
assert len(rec.rows) == 1, "整行被吞了"
value = rec.rows[0]["thinking_observation"]
assert value == "observed"
assert type(value) is str
async def test_an_out_of_domain_verdict_degrades_but_keeps_the_row(self):
"""域外取值挡在落库前,但**降级不丢行**: 列的取值域由库守,代价不是整行。
直接 `ThinkingObservation(x).value` 会在这里抛 `ValueError`,同样被
`_record` `except Exception` 吞成丢整行那只修好了裸 str 一半,
口误值(大小写不符拼错)对测试替身同样自然故降级为 `unknown`
(对库而言本次确实判不出来)并单独告警,与缓存回放的方向选择一致
"""
rec = _MemoryRecorder()
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外
error=None,
reasoning_applies=True,
)
finally:
logger.remove(sink_id)
assert len(rec.rows) == 1, "整行被吞了"
assert rec.rows[0]["thinking_observation"] == "unknown"
hits = [m for m in messages if "OBSERVED" in m]
assert len(hits) == 1, f"域外取值必须单独告警: {messages}"
assert [m for m in messages if "遥测记录失败" in m] == []
async def test_cache_hit_replays_the_recorded_verdict(self):
"""缓存命中回放历史那次的裁定: 与 model/prompt_tokens 同一口径。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_cache_hit(
request=_REQ,
response=_resp(cache_hit=True, thinking_observation=ThinkingObservation.ABSENT),
)
assert rec.rows[0]["thinking_observation"] == "absent"
async def test_terminal_failure_records_unknown(self):
"""终态失败无响应可言,记 `unknown`——它恰好就是"观测不到",不撒谎。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_terminal_failure(
request=_REQ, call_id="c", latency_ms=1, error="dead"
)
value = rec.rows[0]["thinking_observation"]
assert value == "unknown"
assert type(value) is str
async def test_failed_attempt_records_unknown(self):
"""失败尝试(response=None)同理: 默认视图即 UNKNOWN。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
)
assert rec.rows[0]["thinking_observation"] == "unknown"
class TestEmitterReasoningEffort:
"""issue #20: 每行记下这次调用**实际跑在哪档**,否则压测无从分组。
三个入口的取值口径**有意不同**,故逐个钉死: 只有 `emit_attempt` 手上有生效源,
它才谈得上"实际档";另两个入口没有选中源,源级档位无从谈起,只能记请求档
`sampling` 列的现有做法同构
"""
async def test_attempt_records_the_tier_the_transport_applied(self):
"""`nearest` 映射后成功行记的是**映射后**的档,不是请求档。
请求 `medium`模型只有 low/high/max 时二者分叉(实发 `low`)emitter
"顺手"重算 `effective_effort`,记的就是一个从未发出过的档,而两个值在没开
映射的源上恒等本地跑不开映射的源永远看不出这个错
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=ChatRequest(
messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
),
source=_source(effort_fallback="nearest"),
call_id="c",
latency_ms=1,
response=_resp(applied_effort=Effort.LOW),
error=None,
reasoning_applies=True,
)
value = rec.rows[0]["reasoning_effort"]
assert value == "low" # 不是 medium: 那一档从未发出去过
assert type(value) is str # 不是 Effort: 子类实例不得下沉到 recorder
async def test_failed_attempt_falls_back_to_the_requested_tier(self):
"""失败尝试没有响应,实际档不可知,记请求档并接受这层含义差别。
档位错误(resolve Phase 2/4/5)根本没发 HTTP,却照样经
`RequestRejectedError` 走到这里记的正是**被拒绝的那一档**,这对
"哪一档配错了" 是有用信号,不该被过滤掉
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(reasoning_effort=Effort.HIGH),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
)
assert rec.rows[0]["reasoning_effort"] == "high"
async def test_failed_attempt_resolves_the_syntactic_sugar_too(self):
"""回落走 `effective_effort` 而非裸读字段: `enable_thinking` 也是表态。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(enable_thinking=True),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
)
assert rec.rows[0]["reasoning_effort"] == "auto"
async def test_cache_hit_records_the_request_tier_not_the_replayed_one(self):
"""命中行没有选中源,故记请求档;与 model/prompt_tokens 的回放口径相反。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_cache_hit(
request=ChatRequest(
messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
),
response=_resp(cache_hit=True, applied_effort=Effort.LOW),
)
assert rec.rows[0]["reasoning_effort"] == "medium"
async def test_terminal_failure_records_the_request_tier(self):
"""终态失败可能根本没选出源,源级档位无从谈起。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_terminal_failure(
request=ChatRequest(
messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.XHIGH
),
call_id="c",
latency_ms=1,
error="dead",
)
value = rec.rows[0]["reasoning_effort"]
assert value == "xhigh"
assert type(value) is str
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
async def test_silence_lands_as_null(self, emit):
"""谁都没表态时落 `NULL`: `None` 与 `'low'` 必须分得开(设计 §6)。
库并不观测模型内部的默认档,记一个推定值等于把"没看见"说成"发生了"
"""
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None)
if emit == "attempt":
await emitter.emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
elif emit == "cache_hit":
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
else:
await emitter.emit_terminal_failure(
request=_REQ, call_id="c", latency_ms=1, error="dead"
)
assert rec.rows[0]["reasoning_effort"] is None
async def test_a_reasonless_path_never_records_a_tier(self):
"""embedding/OCR 走同一个 emitter,但它们的 payload 里没有推理参数。
源上误配了 `ENABLE_THINKING` ,回落若照算就会给一次 embedding 失败
挂上 `auto` 那一档从来没有也不可能被发出去
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(enable_thinking=True),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=False,
)
assert rec.rows[0]["reasoning_effort"] is None
def test_reasoning_applies_has_no_default(self):
"""上一条测的是"传了 False 会怎样",这条测的是"**漏传**会怎样"
`reasoning_applies` 的约定是不设默认值( `TelemetryRecorder` 同款):库外
无第三方调用者,写全签名成本为零,而默认 `True` 会让将来新增的第四条 emit
路径(又一个非推理客户端)漏传时静默落进 chat 口径一次 embedding 失败被
挂上源上误配的 `auto` ,正是上一条测试要防的形态,却绕过了它的断言
约定只写在 docstring 里是没有执法点的,故在此以 `inspect.signature` 实测
"""
import inspect
param = inspect.signature(TelemetryEmitter.emit_attempt).parameters["reasoning_applies"]
assert param.default is inspect.Parameter.empty
assert param.kind is inspect.Parameter.KEYWORD_ONLY
async def test_an_out_of_domain_tier_degrades_but_keeps_the_row(self):
"""域外取值降级为 `NULL` 且**不丢整行**(遥测必录);与缓存回放同一方向。
`LLMResponse` 无运行时校验,测试替身写裸串完全自然;直接 `Effort(raw)`
`ValueError`, `_record` `except Exception` 吞成丢整行
"""
rec = _MemoryRecorder()
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(applied_effort="lowest"),
error=None,
reasoning_applies=True,
)
finally:
logger.remove(sink_id)
assert len(rec.rows) == 1, "整行被吞了"
assert rec.rows[0]["reasoning_effort"] is None
hits = [m for m in messages if "lowest" in m]
assert len(hits) == 1, f"域外取值必须单独告警: {messages}"
assert [m for m in messages if "遥测记录失败" in m] == []
async def test_a_bare_string_tier_still_lands(self):
"""裸串在域内时照常归一并落库,整行不得丢失。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(applied_effort="max"),
error=None,
reasoning_applies=True,
)
assert len(rec.rows) == 1, "整行被吞了"
value = rec.rows[0]["reasoning_effort"]
assert value == "max"
assert type(value) is str
class TestEmitterObservabilityFields:
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
@@ -1130,6 +1493,7 @@ class TestEmitterObservabilityFields:
latency_ms=42,
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cached_prompt_tokens"] == 64
assert rec.rows[0]["model_reported"] == "m-real"
@@ -1144,6 +1508,7 @@ class TestEmitterObservabilityFields:
latency_ms=7,
response=None,
error="boom",
reasoning_applies=True,
)
assert rec.rows[0]["cached_prompt_tokens"] is None
assert rec.rows[0]["model_reported"] is None
@@ -1194,6 +1559,7 @@ class TestEmitterSamplingColumn:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0}
@@ -1208,6 +1574,7 @@ class TestEmitterSamplingColumn:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp())
await emitter.emit_terminal_failure(
@@ -1240,6 +1607,7 @@ class TestEmitterSamplingColumn:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["sampling"] is None
@@ -1272,6 +1640,7 @@ class TestEmitterCallerDimensions:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
elif emit == "cache_hit":
await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True))
@@ -1325,6 +1694,7 @@ class TestEmitterCallerDimensions:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
row = rec.rows[0]
assert row["tenant_id"] == ""
@@ -1385,6 +1755,7 @@ class TestCostWithCachedTier:
latency_ms=1,
response=full,
error=None,
reasoning_applies=True,
)
await emitter.emit_attempt(
request=_REQ,
@@ -1395,6 +1766,7 @@ class TestCostWithCachedTier:
prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000
),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] == pytest.approx(10.0)
assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2
@@ -1417,6 +1789,7 @@ class TestCostWithCachedTier:
latency_ms=1,
response=_resp(usage_source="unavailable", cached_prompt_tokens=5),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
@@ -1432,6 +1805,7 @@ class TestEmitter:
latency_ms=42,
response=_resp(),
error=None,
reasoning_applies=True,
)
row = rec.rows[0]
assert row["call_id"] == "cid-1" and row["error"] is None
@@ -1448,6 +1822,7 @@ class TestEmitter:
latency_ms=7,
response=None,
error="TransientError: boom",
reasoning_applies=True,
)
row = rec.rows[0]
assert row["error"].startswith("TransientError")
@@ -1479,6 +1854,7 @@ class TestEmitter:
usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion
),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
@@ -1492,6 +1868,7 @@ class TestEmitter:
latency_ms=42,
response=_resp(prompt_tokens=0, completion_tokens=4000),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] == pytest.approx(0.032)
@@ -1525,6 +1902,7 @@ class TestEmitter:
latency_ms=1,
response=None,
error="x",
reasoning_applies=True,
)
assert len(rec.rows[0]["messages"]) < 500 # base64 不整段进库(VT R12)
@@ -1541,6 +1919,7 @@ class TestEmitter:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
) # 不抛(降级不冒泡)
@@ -1637,6 +2016,7 @@ async def _emit_with_cap(messages, *, cap, response=_LONG, thinking=_LONG):
latency_ms=1,
response=_resp(content=response, thinking=thinking),
error=None,
reasoning_applies=True,
)
return rec.rows[0]
+827
View File
@@ -0,0 +1,827 @@
"""推理裁定与对账的行为测试(issue #16/#17 设计 §4-§5)。
判据来自 2026-08-25 实测(findings): MiniMax-M3 在开启档流式路径下返回 185 字符
推理正文却不上报 `completion_tokens_details`, qwen/deepseek 两者都报库因此
不能把任何单一信号当权威本组用例逐条钉死"哪个信号该赢"
"""
import pytest
from loguru import logger
from polygateway.providers import ProviderProfile, ThinkingWire, get_provider
from polygateway.thinking import (
DEFAULT_CAPABILITIES,
ThinkingCapability,
ThinkingUnsupportedError,
effective_effort,
get_capability,
observe_thinking,
reconcile_thinking,
register_capability,
resolve_thinking,
)
from polygateway.types import Effort, ThinkingObservation
_MYSTERY = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
"""形态完全未知的 provider(issue #5 的守卫对象)。
2026-09-04 起默认表 8 段全部有形态,故未知样本改为显式构造测的是**机制**
(不知道怎么表达就报错并指路),不是某个段当时的配置"""
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestObserveThinking:
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
def test_reasoning_text_alone_proves_it_happened(self):
"""推理正文是事实本身: 上游不报 token 数也照样成立(M3 流式实测形态)。"""
assert (
observe_thinking(thinking="先解方程 x+y=35", reasoning_tokens=None)
is ThinkingObservation.OBSERVED
)
def test_blank_text_is_not_evidence(self):
"""纯空白正文不算证据: 网关响应是外部输入,truthy 判据会把空格计成推理(P5)。"""
assert (
observe_thinking(thinking=" \n\t ", reasoning_tokens=None)
is ThinkingObservation.UNKNOWN
)
def test_positive_token_count_proves_it_happened(self):
"""无正文但上游报了推理用量(qwen 非流式形态)。"""
assert observe_thinking(thinking="", reasoning_tokens=205) is ThinkingObservation.OBSERVED
def test_zero_token_count_is_positive_evidence_of_absence(self):
"""`0` 是"上报了且为零",与"没上报"语义不同,故是 ABSENT 而非 UNKNOWN。"""
assert observe_thinking(thinking="", reasoning_tokens=0) is ThinkingObservation.ABSENT
def test_no_signal_at_all_stays_unknown(self):
"""M3 非流式开启档的真实形态: 推理已计费却既无正文也无 token 数。
判成 ABSENT 就是伪装成"没推理"正是 issue #16/#17 的病根。
"""
assert observe_thinking(thinking="", reasoning_tokens=None) is ThinkingObservation.UNKNOWN
def test_text_outranks_a_zero_count(self):
"""转述与事实冲突时事实赢: 正文在,`reasoning_tokens=0` 不能翻案。"""
assert (
observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED
)
@pytest.mark.parametrize("negative", [-1, -205])
def test_negative_token_count_is_not_evidence_of_absence(self, negative):
"""负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。
当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的
docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时,
`> 0 else ABSENT` 会给出一个方向相反的强结论函数自身必须闭合(P5)
"""
assert observe_thinking(thinking="", reasoning_tokens=negative) is (
ThinkingObservation.UNKNOWN
)
class TestThinkingObservationEnum:
def test_values_are_stable_strings(self):
"""取值进遥测落库,改名即历史数据断层。"""
assert ThinkingObservation.OBSERVED == "observed"
assert ThinkingObservation.ABSENT == "absent"
assert ThinkingObservation.UNKNOWN == "unknown"
def test_enum_lives_in_the_innermost_layer(self):
"""枚举必须定义在 `types.py`(最内层)。
它是 `LLMResponse` 的字段类型;定义在决策层 `thinking.py` 会让 `types.py`
反向 import 决策模块,违反 P7 依赖铁律(import-linter 契约执法)
"""
assert ThinkingObservation.__module__ == "polygateway.types"
@pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"])
def test_unknown_strings_are_rejected(bogus):
"""非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。
两处接住这个 ValueError **降级而非作废**(缓存复活内容 + UNKNOWN遥测
照常落行),但降级的前提是构造器真的会拒绝它一旦放行,域外取值就会一路
进到 `LLMResponse` 与遥测列里
"""
with pytest.raises(ValueError):
ThinkingObservation(bogus)
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((Effort.NONE, Effort.AUTO), "实测"))
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:
"""五道关卡(顺序即语义)与 nearest 映射;设计 §4.1。
每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住而顺序
在本函数里**就是**语义(Phase 4 落进 Phase 5 就丢掉"这个模型根本关不掉")
"""
# —— Phase 1: 不表态 ——
def test_phase1_absent_effort_injects_nothing(self):
"""没表态就什么都不注入,用模型自己的默认档(与 `none` 严格区分)。"""
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got.payload == {}
assert got.applied_effort is None
# —— Phase 2: 形态未知 ——
@pytest.mark.parametrize("effort", [Effort.NONE, Effort.AUTO, Effort.HIGH])
def test_phase2_unknown_wire_points_to_register(self, effort):
"""不知道怎么发就报错并指路;静默放行是 issue #5 修掉的那种欺骗。
文案必须报出**请求的档位**而非"开/关"方向: `Effort` 是非空字符串,拿它
的真值判方向会把 `none` 说成"开启形态未知",指错了排查方向
"""
with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc:
resolve_thinking(_MYSTERY, None, effort, model="kimi-k3")
msg = str(exc.value)
assert "extra_body" in msg
assert "kimi-k3" in msg
assert effort.value in msg
def test_phase2_reads_the_form_the_asked_for_tier_needs(self):
"""请求 `none` 只需要**关闭**形态: 开启形态未知与这次请求无关。
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即按请求方向
取字段;档位化后一度写成"只看 `on_base`",于是一个已注册了关闭形态的自定义
provider 在请求 `none` 时被误拒,还被指向它已经做过的 `register_provider`
指错方向比不指更糟(设计 §2 处置表第 2 ,2026-09-05 独立验证查出)
"""
profile = ProviderProfile(
name="off_only",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None
),
strip_think_tags=False,
)
cap = ThinkingCapability((Effort.NONE, Effort.AUTO), "构造: 关得掉,开启形态却未登记")
got = resolve_thinking(profile, cap, Effort.NONE, model="x-1")
assert got.payload == {"thinking": {"type": "disabled"}}
assert got.applied_effort is Effort.NONE
@pytest.mark.parametrize("effort", [Effort.AUTO, Effort.HIGH])
def test_phase2_still_fires_when_the_on_form_is_the_missing_half(self, effort):
"""反方向不得被一并放过: 要开推理而开启形态未知,仍须报错并指路注册。"""
profile = ProviderProfile(
name="off_only",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}}, on_base=None, effort_key=None
),
strip_think_tags=False,
)
cap = ThinkingCapability((Effort.NONE, Effort.AUTO, Effort.HIGH), "构造")
with pytest.raises(ThinkingUnsupportedError, match="register_provider") as exc:
resolve_thinking(profile, cap, effort, model="x-1")
assert effort.value in str(exc.value)
def test_phase2_beats_the_capability_checks(self):
"""形态未知时无从注入,能力如何无关紧要——Phase 2 必须先于 4/5。"""
cap = ThinkingCapability((Effort.AUTO,), "构造")
with pytest.raises(ThinkingUnsupportedError, match="register_provider"):
resolve_thinking(_MYSTERY, cap, Effort.NONE, model="whatever")
# —— Phase 3: 能力未登记 ——
def test_phase3_unregistered_warns_then_injects(self):
"""新模型上线不该被库挡住,但也不该假装成功: 喊一声再尽力注入。"""
messages, sink_id = _warnings()
try:
got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9")
finally:
logger.remove(sink_id)
assert got.payload == {"reasoning_effort": "none"}
assert got.applied_effort is Effort.NONE
assert any("MiniMax-M9" in m for m in messages)
def test_phase3_can_be_silenced_on_the_hot_path(self):
"""装配期已经喊过一次,逐次调用再喊只会刷屏;判定结果不受影响。"""
messages, sink_id = _warnings()
try:
got = resolve_thinking(
get_provider("minimax"),
None,
Effort.NONE,
model="MiniMax-M9",
warn_unregistered=False,
)
finally:
logger.remove(sink_id)
assert got.payload == {"reasoning_effort": "none"}
assert not [m for m in messages if "MiniMax-M9" in m]
def test_phase3_does_not_validate_tiers(self):
"""能力未知就没有清单可比对,拿空清单去拒绝档位等于凭空报错。"""
got = resolve_thinking(
get_provider("zhipu"),
None,
Effort.XHIGH,
model="glm-9-not-registered",
warn_unregistered=False,
)
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "xhigh"}
assert got.applied_effort is Effort.XHIGH
# —— Phase 4: 关不掉 ——
def test_phase4_before_phase5(self):
"""请求 `none` 而模型关不掉: 文案必须给出可执行替代与 env 键名。
若落进 Phase 5 的通用分支,报错会退化成"不支持 none,可选 low/high/max",
丢掉"这个模型根本关不掉"这个关键信息下游随后就会去找 extra_body 那条
绕过的路,而那正是 issue #20 的成因。
"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3")
msg = str(exc.value)
assert "glm-5.3" in msg
assert "'low'" in msg, "必须给出 cheapest_effort 的值"
assert "REASONING_EFFORT" in msg, "必须给出 env 键名"
assert "可选档位" not in msg, "退化成 Phase 5 的通用文案即失去可执行替代"
def test_phase4_never_maps_even_with_nearest(self):
"""`none` 不走映射: 把"关不掉"映射成"开着最低档"就是又一次静默降级。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError, match="REASONING_EFFORT"):
resolve_thinking(
get_provider("zhipu"), cap, Effort.NONE, model="glm-5.3", fallback="nearest"
)
def test_phase4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。
期望片段 2026-09-05 `{}` 改成 minimax `on_base` 实际值: issue #21 把
该段的""改回带 medium(T2 "开档不注入"是推定,T10 实测推翻)本用例守的
Phase 4 只拦关闭方向,注入什么由 wire 决定,故随 wire
"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, Effort.AUTO, model="MiniMax-M2.7")
assert got.payload == {"reasoning_effort": "medium"}
assert got.applied_effort is Effort.AUTO
def test_phase4_passes_when_none_is_registered(self):
cap = get_capability("MiniMax-M3")
got = resolve_thinking(get_provider("minimax"), cap, Effort.NONE, model="MiniMax-M3")
assert got.payload == {"reasoning_effort": "none"}
assert got.applied_effort is Effort.NONE
# —— Phase 5: 档位打空 ——
def test_phase5_lists_tiers_for_tiered_model(self):
"""档位型模型: 文案必须列出它真有的档,否则下游只能猜。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
msg = str(exc.value)
assert "medium" in msg and "glm-5.3" in msg
assert "可选档位" in msg
assert "low" in msg and "high" in msg and "max" in msg
def test_phase5_says_toggle_only_for_switch_model(self):
"""纯开关型模型没有档位,对它说"可选档位"是错的(设计 §3.2 第三个派生量)。
样本 2026-09-05 MiniMax-M3 换成 glm-4.6v: T10 实测 M3 的六个强度值全部生效,
它不再是纯开关型;glm-4.6v 是实测证据最硬的 (none, auto) 模型, zhipu wire
effort_key这两点缺一不可,否则命中的是"该 provider 没有档位键"那条分支
"""
cap = get_capability("glm-4.6v") # (none, auto): 能开能关,但没有强度档
with pytest.raises(ThinkingUnsupportedError) as exc:
resolve_thinking(get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v")
msg = str(exc.value)
assert "可选档位" not in msg
assert "该模型只有开关" in msg
assert "auto" in msg and "none" in msg
def test_phase5_wording_forks_on_is_tiered(self):
"""两条分叉必须真的不同——同一句话套两种模型等于没分叉。"""
with pytest.raises(ThinkingUnsupportedError) as tiered:
resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.3"), Effort.MEDIUM, model="glm-5.3"
)
with pytest.raises(ThinkingUnsupportedError) as switch:
resolve_thinking(
get_provider("zhipu"),
get_capability("glm-4.6v"),
Effort.MEDIUM,
model="glm-4.6v",
)
assert str(tiered.value) != str(switch.value)
def test_phase5_passes_a_supported_tier(self):
cap = get_capability("glm-5.3")
got = resolve_thinking(get_provider("zhipu"), cap, Effort.MAX, model="glm-5.3")
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "max"}
assert got.applied_effort is Effort.MAX
def test_auto_never_trips_phase5(self):
"""`auto` = 不指定档位,可满足性只取决于 wire 有没有 on_base。
它不是写进 `effort_key` 的取值,故不受档位清单约束反过来判会让存量的
`ENABLE_THINKING=true`(T5 起等价于 auto) deepseek/glm-5.3 这类清单里
没有 auto 的模型上当场报错设计 §12 明确承诺存量配置继续可跑
"""
cap = get_capability("deepseek-v4-pro") # (none, high, max),清单里没有 auto
got = resolve_thinking(get_provider("deepseek"), cap, Effort.AUTO, model="deepseek-v4-pro")
assert got.payload == {"thinking": {"type": "enabled"}}
assert got.applied_effort is Effort.AUTO
# —— nearest 映射(fallback 的逃生口)——
def test_nearest_ties_go_cheaper(self):
"""等距取弱: 省钱优先,库不替下游涨价(一次 medium→max 是数倍账单)。"""
cap = get_capability("glm-5.3") # (low, high, max)
messages, sink_id = _warnings()
try:
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
)
finally:
logger.remove(sink_id)
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}
assert any("glm-5.3" in m and "medium" in m and "low" in m for m in messages)
def test_nearest_ties_go_cheaper_on_the_strong_side_too(self):
"""xhigh 与 high/max 位序各差 1,同样取弱侧——规则不因方向而变。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.XHIGH, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.HIGH
def test_nearest_goes_up_when_the_only_neighbour_is_stronger(self):
"""minimal 之下无档可选,映射必须上行到 low,而不是无解报错。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MINIMAL, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.LOW
def test_nearest_never_turns_reasoning_off(self):
"""请求"想得浅一点"绝不能被映射成"别想了": 那是方向反转,不是省钱。"""
cap = get_capability("glm-4.6v") # (none, auto)
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.HIGH, model="glm-4.6v", fallback="nearest"
)
assert got.applied_effort is Effort.AUTO
assert got.payload == {"thinking": {"type": "enabled"}}
def test_nearest_still_errors_when_no_on_tier_exists(self):
"""只能关不能开的模型,映射无解——报错而非挑一个反向的档。"""
cap = ThinkingCapability((Effort.NONE,), "构造: 只登记了关闭档")
with pytest.raises(ThinkingUnsupportedError, match="only-off"):
resolve_thinking(
get_provider("minimax"), cap, Effort.HIGH, model="only-off", fallback="nearest"
)
def test_error_fallback_is_the_default(self):
"""默认关闭映射的理由是钱: 静默的 medium→max 在 GLM-5.3 上是数倍账单。"""
cap = get_capability("glm-5.3")
with pytest.raises(ThinkingUnsupportedError):
resolve_thinking(get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3")
def test_resolution_reports_applied_effort_after_mapping(self):
"""遥测记的必须是**实际**发出去的档,否则压测按档分组时挂在从未发出的档下。"""
cap = get_capability("glm-5.3")
got = resolve_thinking(
get_provider("zhipu"), cap, Effort.MEDIUM, model="glm-5.3", fallback="nearest"
)
assert got.applied_effort is Effort.LOW
assert got.applied_effort is not Effort.MEDIUM
# —— 注入形态 ——
def test_auto_injects_on_base_only(self):
"""`auto` 逐字节等于旧的 `thinking_on`: 开启,但不附任何档位。"""
got = resolve_thinking(
get_provider("qwen"), get_capability("qwen3.7-plus"), Effort.AUTO, model="qwen3.7-plus"
)
assert got.payload == {"enable_thinking": True}
def test_effort_key_none_rejects_a_tier(self):
"""qwen 系只有开关没有档位键: 硬塞一个档位只会发出一个厂商不认的字段。"""
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 假设它有档位")
with pytest.raises(ThinkingUnsupportedError, match="没有档位键"):
resolve_thinking(get_provider("qwen"), cap, Effort.LOW, model="qwen-hypothetical")
def test_provider_without_an_off_form_says_which_half_is_missing(self):
"""`off is None` ≠ `on_base is None`: 前者是"关不了",后者是"不知道怎么发""""
profile = ProviderProfile(
name="no_off",
thinking=ThinkingWire(off=None, on_base={}, effort_key="reasoning_effort"),
strip_think_tags=False,
)
cap = ThinkingCapability((Effort.NONE, Effort.LOW), "构造: 能力表说能关,形态却没有")
with pytest.raises(ThinkingUnsupportedError, match="没有关闭形态") as exc:
resolve_thinking(profile, cap, Effort.NONE, model="x-1")
assert "register_provider" not in str(exc.value), "形态已知,不该指向注册"
# —— 归一化: 本函数是档位进入库内的第四条入口(设计 §4.4) ——
def test_a_bare_string_tier_is_normalised_at_the_door(self):
"""`resolve_thinking` 在 `__all__` 里,下游直调时传的天然是裸串。
第三参数本次由 `bool` 换成 `Effort`,而下游最自然的写法是从 JSON/配置读出来
`"low"`不在入口归一,`_inject` `.value` 抛的是 `AttributeError`
一个未文档化也不属错误四分类的异常(2026-09-05 独立验证查出)
"""
got = resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.3"), "low", model="glm-5.3"
)
assert got.payload == {"thinking": {"type": "enabled"}, "reasoning_effort": "low"}
assert got.applied_effort is Effort.LOW
def test_a_bare_none_string_still_means_the_off_tier(self):
"""裸 `"none"` 必须走到关闭形态,而不是被当成某个开启档。
身份比较 `"none" is Effort.NONE` 恒假,漏归一的后果是**静默判否**:
`_wire_unknown_for` `effort is not Effort.NONE` 恒真,于是关闭请求会去看
`on_base`正是设计 §2 处置表第 2 条点名要避免的误判方向
"""
got = resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.2"), "none", model="glm-5.2"
)
assert got.payload == {"thinking": {"type": "disabled"}}
assert got.applied_effort is Effort.NONE
def test_a_bare_none_string_reaches_phase4_on_a_model_that_cannot_disable(self):
"""漏归一时 Phase 4 整条被绕过: 关不掉的模型会被静默放行成"开启""""
with pytest.raises(ThinkingUnsupportedError, match="无法关闭推理") as exc:
resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.3"), "none", model="glm-5.3"
)
assert "'low'" in str(exc.value), "Phase 4 的可执行替代不能丢"
def test_an_illegal_tier_string_names_this_function_as_the_origin(self):
"""非法档位报 `ValueError` 并指回**是哪一处**填错——档位有四条入口,不说清
就得让人自己去翻"""
with pytest.raises(ValueError, match="resolve_thinking") as exc:
resolve_thinking(
get_provider("zhipu"), get_capability("glm-5.3"), "lowest", model="glm-5.3"
)
assert "非法推理档位" in str(exc.value)
class TestReconcileThinking:
"""声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。
文案本身是被断言对象判定与日志分离正是为此: 告警内容可直接比对,不必
去解析日志格式
判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` "要求
关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次
真正发出去的那一档, transport 的节流键正按它分离,两者必须同源
"""
_CAP = ThinkingCapability(
(Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭"
)
def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
msg = reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
assert "2026-08-02 实测 reasoning_effort=none 可关闭" in msg
assert "register_capability" in msg
def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
msg = reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M9",
)
assert msg is not None
assert "MiniMax-M9" in msg
assert "能力表" not in msg
assert "register_capability" in msg
def test_registered_and_unregistered_wordings_differ(self):
registered = reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
unregistered = reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M3",
)
assert registered != unregistered
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_absent_is_a_contradiction(self, capability):
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
msg = reconcile_thinking(
effort=Effort.AUTO,
observation=ThinkingObservation.ABSENT,
capability=capability,
model="qwen3.7-plus",
)
assert msg is not None
assert "qwen3.7-plus" in msg
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果""""
msg = reconcile_thinking(
effort=Effort.AUTO,
observation=ThinkingObservation.UNKNOWN,
capability=capability,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
def test_off_and_absent_stays_silent(self):
"""要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。
这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是
"没有证伪力",这里是"正面证实要求已满足"两者都必须沉默,漏测哪一格,
Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住
"""
assert (
reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.ABSENT,
capability=self._CAP,
model="qwen3.7-plus",
)
is None
)
def test_off_and_unknown_stays_silent(self):
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
assert (
reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.UNKNOWN,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
@pytest.mark.parametrize(
"observation",
[ThinkingObservation.OBSERVED, ThinkingObservation.ABSENT, ThinkingObservation.UNKNOWN],
)
def test_no_request_no_grievance(self, observation):
"""调用方不表态,就无从谈"违背""""
assert (
reconcile_thinking(
effort=None,
observation=observation,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
def test_on_and_observed_is_exactly_what_was_asked_for(self):
assert (
reconcile_thinking(
effort=Effort.AUTO,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
@pytest.mark.parametrize("effort", [Effort.LOW, Effort.HIGH, Effort.MAX])
def test_a_strength_tier_is_an_on_request_not_an_off_one(self, effort):
"""强度档必须走"要求开启"一支: 观测到推理正是它要的结果,不得报警。
判据写成真值性(`if not effort`)会在这里翻车`Effort.NONE` 的取值是
非空串 `"none"`,恒为真;那种写法会把每一个强度档都送进"要求关闭"分支,
于是"想了"被当成矛盾,"没想"反倒沉默,告警方向整个颠倒
"""
assert (
reconcile_thinking(
effort=effort,
observation=ThinkingObservation.OBSERVED,
capability=ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "构造"),
model="glm-5.3",
)
is None
)
def test_the_wording_names_the_tier_that_was_asked_for(self):
"""文案要写出**本次这一档**: 节流键按档分离,文案不分档就看不出是哪一档。"""
low = reconcile_thinking(
effort=Effort.LOW,
observation=ThinkingObservation.ABSENT,
capability=None,
model="glm-5.3",
)
max_ = reconcile_thinking(
effort=Effort.MAX,
observation=ThinkingObservation.ABSENT,
capability=None,
model="glm-5.3",
)
assert low is not None and max_ is not None
assert "low" in low and "max" in max_
assert low != max_
def test_none_and_observed_is_the_issue_20_contradiction(self):
"""请求 `none` 却观测到推理 —— issue #20 要恢复的那条报警,判据是**档位相等**。
与上一条互为对照: 同样是 OBSERVED,`none` 必须喊强度档必须沉默把分支
条件写反(`is not Effort.NONE`)会让这两条同时红,单有一条则抓不住
"""
msg = reconcile_thinking(
effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="glm-5.3",
)
assert msg is not None and "none" in msg
class TestEffortVocabulary:
"""八档封闭词汇(设计 §3.1);`auto` 不可省——9 个纯开关型模型无强度档可填。"""
def test_none_and_auto_are_distinct_members(self):
assert Effort.NONE != Effort.AUTO
assert Effort("none") is Effort.NONE
assert Effort("auto") is Effort.AUTO
def test_vocabulary_is_exactly_eight(self):
assert len(list(Effort)) == 8
def test_values_are_wire_literals(self):
# 档位值直接写进请求体,改名即改变发出去的字节
assert [e.value for e in Effort] == [
"none",
"auto",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]
class TestCapabilityTierList:
"""能力表从 bool 变成档位清单(设计 §3.2);三个派生量不存字段,存了必漂移。"""
def test_capability_derives_can_disable(self):
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").can_disable is True
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").can_disable is False
def test_cheapest_effort_skips_none(self):
# 「关不掉时的可执行替代」取的是除 none 外最弱的一档
assert (
ThinkingCapability((Effort.LOW, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
is Effort.LOW
)
assert (
ThinkingCapability((Effort.NONE, Effort.HIGH, Effort.MAX), "实测").cheapest_effort
is Effort.HIGH
)
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").cheapest_effort is Effort.AUTO
assert ThinkingCapability((Effort.AUTO,), "实测").cheapest_effort is Effort.AUTO
def test_cheapest_effort_is_none_when_only_none(self):
# 只能关不能开: 没有可推荐的「最省的开启档」
assert ThinkingCapability((Effort.NONE,), "实测").cheapest_effort is None
def test_is_tiered_excludes_none_and_auto(self):
# 纯开关型模型不该被告知「可选档位」——它没有档位
assert ThinkingCapability((Effort.NONE, Effort.AUTO), "实测").is_tiered is False
assert ThinkingCapability((Effort.AUTO,), "实测").is_tiered is False
assert ThinkingCapability((Effort.LOW, Effort.MAX), "实测").is_tiered is True
def test_empty_efforts_rejected(self):
with pytest.raises(ValueError, match="至少"):
ThinkingCapability((), "实测")
def test_duplicate_efforts_rejected(self):
with pytest.raises(ValueError, match="重复"):
ThinkingCapability((Effort.LOW, Effort.LOW), "实测")
def test_glm53_cannot_be_disabled(self):
# 三源一致(智谱官方文档/cherry-studio/OpenRouter): thinking.type 只接受 enabled
cap = get_capability("glm-5.3")
assert cap is not None
assert cap.can_disable is False
assert cap.cheapest_effort is Effort.LOW
def test_m2_series_still_cannot_be_disabled(self):
# 迁移回归: 旧表用 can_disable=False 表达的事实,新表用「none 不在清单里」表达
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
assert get_capability("MiniMax-M3").can_disable is True
class TestEffectiveEffort:
"""三层优先级的**唯一**判定处(设计 §4.2): 请求级 > 源级 > 语法糖 > 不表态。
收口成一个纯函数,是因为它此前在装配守卫与 transport 里各写了一份就地转换:
两份各自演化的判定,迟早会在"装配期放行、运行期报错"这种最难查的形态上分叉
"""
def test_request_beats_source(self):
assert (
effective_effort(
request_effort=Effort.MAX, source_effort=Effort.LOW, enable_thinking=None
)
is Effort.MAX
)
def test_source_beats_sugar(self):
assert (
effective_effort(request_effort=None, source_effort=Effort.HIGH, enable_thinking=None)
is Effort.HIGH
)
def test_none_request_does_not_clear_source(self):
"""请求级"没表态"绝不能被读成"要求关闭"——那会静默改掉源级的默认档。"""
assert (
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=None)
is Effort.LOW
)
def test_request_none_tier_is_an_opinion(self):
"""`Effort.NONE` 是一次明确的表态,必须压过源级档位而不是被当成缺省。"""
assert (
effective_effort(
request_effort=Effort.NONE, source_effort=Effort.MAX, enable_thinking=None
)
is Effort.NONE
)
def test_enable_thinking_true_is_auto(self):
"""`True` → `auto`(开启但不指定强度),而**不是**旧版硬编码的 medium。"""
assert (
effective_effort(request_effort=None, source_effort=None, enable_thinking=True)
is Effort.AUTO
)
def test_enable_thinking_false_is_the_none_tier(self):
assert (
effective_effort(request_effort=None, source_effort=None, enable_thinking=False)
is Effort.NONE
)
def test_sugar_is_the_last_word_only(self):
"""语法糖排在最末: 显式配了档位就以档位为准(矛盾组合已被构造期挡下)。"""
assert (
effective_effort(request_effort=None, source_effort=Effort.LOW, enable_thinking=True)
is Effort.LOW
)
def test_all_absent_is_no_opinion(self):
"""三层都不表态 → None(随模型默认),与 `Effort.NONE` 严格区分。"""
assert (
effective_effort(request_effort=None, source_effort=None, enable_thinking=None) is None
)
+83
View File
@@ -10,10 +10,12 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
LLMResponse,
RetryPolicy,
SourceConfig,
ThinkingObservation,
TransportResult,
Usage,
)
@@ -33,6 +35,18 @@ def _make_source(**overrides):
return SourceConfig(**base)
class TestThinkingObservationLayering:
"""枚举必须留在最内层,别被后来的重构挪进决策模块。"""
def test_defined_in_types_not_in_thinking(self):
"""`LLMResponse` 拿它当字段类型,定义在 `thinking.py` 会让最内层反向依赖决策层。
这条不是风格洁癖: import-linter 会判红,但那要等代码写完才发现;本用例
把约束前移到类型层面
"""
assert ThinkingObservation.__module__ == "polygateway.types"
class TestLLMResponse:
def test_eleven_legacy_fields_positional(self):
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
@@ -75,6 +89,30 @@ class TestLLMResponse:
assert filled.model_reported == "MiniMax-Text-01-250321"
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
def test_thinking_observation_defaults_to_unknown(self):
"""issue #16/#17: 默认必须是 UNKNOWN——"没信号"不得被伪装成"没推理"
默认值取 ABSENT 会让每个不填该字段的构造点(测试 fake其他 transport)
都在替上游做一个它没做过的声明,那正是本 issue 要消灭的静默错觉
"""
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
filled = LLMResponse(
"c",
"t",
"m",
"p",
1,
2,
3,
None,
None,
False,
"cid",
thinking_observation=ThinkingObservation.OBSERVED,
)
assert filled.thinking_observation is ThinkingObservation.OBSERVED
def test_frozen(self):
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
with pytest.raises(dataclasses.FrozenInstanceError):
@@ -251,6 +289,8 @@ class TestAuxTypes:
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
assert s.cached_prompt_tokens is None and s.model_reported is None
assert s.reasoning_tokens is None
# issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理"
assert s.thinking_observation is ThinkingObservation.UNKNOWN
class TestOcrTypes:
@@ -527,3 +567,46 @@ class TestChatRequestDimensions:
)
assert request.tenant_id == "t1"
assert request.meta == {"batch": "b-42"}
class TestSourceConfigEffortNormalization:
"""源级档位在**构造期**归一成 `Effort`(issue #20;2026-09-05 独立验证查出)。
库内一律用 `is Effort.NONE` 做身份比较, `Effort` `StrEnum`下游从
JSON/配置读出来的天然是裸字符串,不归一就会在**错误路径上**误判并二次崩溃
"""
def test_bare_string_tier_is_normalized(self):
"""`reasoning_effort="low"` 必须存成 `Effort.LOW`,而不是原样留个 str。"""
assert _make_source(reasoning_effort="low").reasoning_effort is Effort.LOW
def test_whitespace_and_case_are_normalized(self):
"""与 `.env` 那条路同口径: 行尾空格与大写写法是常态,档位无大小写语义。"""
assert _make_source(reasoning_effort=" LOW ").reasoning_effort is Effort.LOW
def test_consistent_bare_string_survives_the_contradiction_guard(self):
"""设计 §4.2 明说"二者一致则放行",裸字符串写法不得被判成矛盾。
修复前实测: `("none" is Effort.NONE)` 为假 判为矛盾 拼文案时 `.value`
`AttributeError`,连承诺的 `ValueError` 都拿不到
"""
source = _make_source(enable_thinking=False, reasoning_effort="none")
assert source.reasoning_effort is Effort.NONE
def test_contradiction_still_caught_through_a_bare_string(self):
"""归一化不得把矛盾一并抹平: `True` + `"none"` 仍是配置错误。"""
with pytest.raises(ValueError, match="矛盾"):
_make_source(enable_thinking=True, reasoning_effort="none")
def test_illegal_tier_lists_the_whole_vocabulary(self):
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来并指回字段。"""
with pytest.raises(ValueError) as exc:
_make_source(reasoning_effort="lowest")
message = str(exc.value)
assert "reasoning_effort" in message
assert all(tier.value in message for tier in Effort)
def test_non_string_tier_is_a_value_error_not_a_crash(self):
"""非字符串同样只能是 `ValueError`: 公共入口不许把类型错误漏成 `AttributeError`。"""
with pytest.raises(ValueError, match="推理档位"):
_make_source(reasoning_effort=3)
+3
View File
@@ -134,6 +134,7 @@ async def test_salvage_override_stays_in_domain(usage):
stream=True,
overlay={},
call_id="cid",
reasoning_effort=None,
)
assert result.usage_source in USAGE_SOURCES
@@ -261,6 +262,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
latency_ms=10,
response=_resp(emitted),
error=None,
reasoning_applies=True,
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
@@ -275,6 +277,7 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
latency_ms=10,
response=None,
error="boom",
reasoning_applies=True,
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
+1 -1
View File
@@ -28,7 +28,7 @@ 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
from polygateway.telemetry.sqlite import COLUMNS as TELEMETRY_COLUMNS
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
+82 -8
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import argparse
import asyncio
import re
import sqlite3
import sys
from datetime import UTC, datetime, timedelta
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
TABLE = "llm_calls"
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
EXIT_OK = 0
EXIT_USAGE = 1
@@ -59,6 +65,11 @@ _EPILOG = """\
时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行;
--older-than-days 0 "删除此刻之前的全部行"
--table: 本脚本只清理表 llm_calls, --table 只有 schema 一段可变(写成
--table <schema>.llm_calls)给了它,目标就由参数精确解析不再经
search_path 推断含点或引号的复杂标识符不支持,此时请不给 --table,
退回 search_path 解析那条路径
示例:
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
--older-than-days 90 # dry-run,只看会删什么
@@ -114,6 +125,11 @@ def _build_parser() -> _Parser:
action="store_true",
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
)
parser.add_argument(
"--table",
metavar="SCHEMA.NAME",
help=f"仅 postgres: 把目标钉死为 <schema>.{TABLE},绕开 search_path 推断",
)
return parser
@@ -150,10 +166,16 @@ def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
parser.error("--backend sqlite 不接受 --dsn")
if args.batch_size is not None:
parser.error("--batch-size 仅用于 --backend postgres")
if args.table is not None:
parser.error("--table 仅用于 --backend postgres:SQLite 的库文件即目标,无 schema 可消歧")
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。
`--table` 在此解析成 `args.table_schema`(未给则 None): 校验与解析放在同一处,
后面的执行路径就只面对一个已经合法的 schema ,不必再重复判断
"""
if args.dsn is None:
parser.error("--backend postgres 需要 --dsn")
if args.path is not None:
@@ -164,6 +186,36 @@ def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
args.batch_size = 1000
elif args.batch_size < 1:
parser.error("--batch-size 必须 >= 1")
args.table_schema = None if args.table is None else _parse_table(parser, args.table)
def _parse_table(parser: _Parser, value: str) -> str:
"""校验 `--table SCHEMA.NAME` 并返回 schema 段;任何不合法形态退出 1。
**表名段为什么不可变**: 只校验"两段、非空"的话,一次手误 `--table audit.events`
就会让本脚本对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE
脚本的名字退出码 3 的分区提示README 的定位全都围绕遥测表写,它从未声称自己
是通用清理器;把这条校验去掉等于在一个拿 DELETE 权限跑的脚本上开静默的口子
"""
segments = value.split(".")
if len(segments) != 2:
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
schema, name = segments
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态
if not schema or not name:
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
if not _PLAIN_IDENTIFIER.fullmatch(schema):
parser.error(
f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/"
f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持,"
"这种情形请不给 --table,退回 search_path 解析那条路径。"
)
if name != TABLE:
parser.error(
f"--table 的表名段必须逐字等于 {TABLE}:本脚本只清理遥测表 {TABLE},"
f"不是通用清理器,当前: {value!r}"
)
return schema
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
@@ -240,7 +292,9 @@ def _quote(identifier: str) -> str:
return f'"{escaped}"'
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
async def _run_postgres(
dsn: str, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
) -> int:
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
try:
import asyncpg
@@ -257,7 +311,7 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
return EXIT_BACKEND
try:
return await _purge_postgres(conn, cutoff, apply_, batch_size)
return await _purge_postgres(conn, cutoff, apply_, batch_size, table_schema)
except asyncpg.PostgresError as exc:
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
return EXIT_BACKEND
@@ -265,23 +319,41 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
await conn.close()
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
async def _purge_postgres(
conn: Any, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
) -> int:
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
# 给了 --table 就用引号限定名精确解析(绕开 search_path),否则维持裸表名解析——
# 后者走连接自己的 search_path,故无论哪条路都必须把解析结果打出来:"我删的到底是
# 哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
lookup = TABLE if table_schema is None else f"{_quote(table_schema)}.{_quote(TABLE)}"
target = await conn.fetchrow(
"SELECT n.nspname AS schema, c.relname AS name, "
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass($1)",
TABLE,
lookup,
)
if target is None:
# 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
# search_path 找不到则是连接配置的事。
if table_schema is None:
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
else:
print(
f"显式指定的表 {table_schema}.{TABLE} 不存在或当前角色不可见。"
"注意: PG 中未加引号建的标识符在 catalog 里是小写。",
file=sys.stderr,
)
return EXIT_BACKEND
schema, name = target["schema"], target["name"]
qualified = f"{_quote(schema)}.{_quote(name)}"
print(f"目标表: {schema}.{name}")
if apply_ and table_schema is None:
# 只在 --apply 时提示: dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途。
print(
"注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。"
)
if target["partitioned"]:
print(
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
@@ -347,7 +419,9 @@ def main(argv: Sequence[str] | None = None) -> int:
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
if args.backend == "sqlite":
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
return asyncio.run(
_run_postgres(args.dsn, cutoff, args.apply, args.batch_size, args.table_schema)
)
if __name__ == "__main__":