47 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
56 changed files with 6288 additions and 680 deletions
+15 -2
View File
@@ -2,7 +2,8 @@
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。 # 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══ # ══ 多源配置: {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 作缺省)。 # 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
LLM__QWEN__1__BASE_URL= LLM__QWEN__1__BASE_URL=
LLM__QWEN__1__API_KEY= 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__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout # LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15 # 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__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连) # LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串) # LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
+111
View File
@@ -1,5 +1,116 @@
# Changelog # 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) ## 1.3.1(2026-08-26)
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。 「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
+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 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: install:
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]" conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
test: test:
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing 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) ruff check src/ tests/ --fix
conda run -n $(ENV) lint-imports conda run -n $(ENV) lint-imports
format: format:
conda run -n $(ENV) ruff format src/ tests/ 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 format --check src/ tests/
conda run -n $(ENV) ruff check src/ tests/ conda run -n $(ENV) ruff check src/ tests/
conda run -n $(ENV) lint-imports conda run -n $(ENV) lint-imports
+8 -5
View File
@@ -16,10 +16,11 @@
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) | | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`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]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;请求方向与实测观测矛盾时按 `(模型, 方向)` 各告警一次(能力表过期、开启未生效、注入了却观测不到);裁定结果随遥测落库 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 25 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 推理档位 | 推理是**八档**(`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` 配小了的唯一信号 | | 遥测的资源与降级 | 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、不建索引** | | 调用方维度 | 每次调用可带 `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` | | 遥测表治理 | `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` |
@@ -352,7 +353,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` | | 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` | | 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS | | 访问控制 | 上面的三角色 + `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 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。 **`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
@@ -362,6 +363,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。 `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) 取,用维护角色跑。 该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
## 错误模型(四分类) ## 错误模型(四分类)
@@ -400,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}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) | | `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) | | `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.1" version = "1.3.3"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+9 -5
View File
@@ -372,7 +372,7 @@ flowchart TB
| `cache_hit` | bool | 是否缓存命中 | | `cache_hit` | bool | 是否缓存命中 |
| `call_id` | str | UUID,每次**尝试**独立 | | `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,见下)、`thinking_observation`(2026-08-25,issue #16/#17,见下)。 新增字段(库扩展,全部带默认值): `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 的调用审计需求)**: **可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
@@ -395,7 +395,7 @@ flowchart TB
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。 判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
**声明 × 观测对账(同批)**: `reconcile_thinking`请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown``None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。 **声明 × 观测对账(同批;判据 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 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。 这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
@@ -524,12 +524,14 @@ flowchart TB
### 7.5 响应缓存 ### 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 稳定性不变。 - `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。 - `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。 - `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 排序去重——按模型而非源名,改源名不误触冷启动)。 - `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。 - **两条已知副作用**: ① 逐 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` 的原始响应不缓存(避免固化坏结果)。 - value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
@@ -539,7 +541,7 @@ flowchart TB
### 7.7 多源与选源 ### 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。 **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。
@@ -551,7 +553,7 @@ flowchart TB
### 7.8 遥测与成本 ### 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**、**thinking_observation**。 **必录字段**(继承三项目 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。 **`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。
@@ -559,6 +561,8 @@ 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` 路径,失败仍只逐行降级、不判死。 **`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` 独立成一档而不再被并进「未推理」。 **`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 同步。 **recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter``_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
@@ -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/`,两者职责不重叠。 > **定位**: 用户文档站 = 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 同步(发布检查清单第一项)。 **门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md``README.md``.env.example``research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。
## 3. 写作纪律 ## 3. 写作纪律
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。 - 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
@@ -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 上,测出来的是个假现场。
+24
View File
@@ -200,6 +200,16 @@
"id": "plan:plan-issue15-telemetry-pool-lifecycle", "id": "plan:plan-issue15-telemetry-pool-lifecycle",
"label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)", "label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)",
"type": "plan" "type": "plan"
},
{
"id": "design:reasoning-effort",
"label": "推理档位一等化设计(issue #20 及其一般形式)",
"type": "design"
},
{
"id": "plan:reasoning-effort",
"label": "实现计划: 推理档位一等化",
"type": "plan"
} }
], ],
"links": [ "links": [
@@ -405,6 +415,20 @@
"relation": "refines", "relation": "refines",
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制", "evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
"added": "2026-08-26T04:49:18.648857+00:00" "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"
} }
] ]
} }
+12 -5
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-08-26 04:49 UTC > 自动生成,更新时间:2026-09-05 04:07 UTC
## design (38) ## 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-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-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` - [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-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-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-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` - [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-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [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 #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode` - [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 #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` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
@@ -38,17 +40,19 @@
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe` - [建表前先探测,判死只认「确定写不进去」](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` - [推理可观测性一等化(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` - [推理开关能力建模与 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` - [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (13) ## 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-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-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-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-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` - [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 #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 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload` - [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance` - [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
@@ -57,7 +61,7 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [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` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (33) ## 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-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-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` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -73,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-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-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-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` - [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` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
@@ -85,6 +91,7 @@
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` - [实现计划: 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` - [实现计划: 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` - [实现计划: 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` - [实现计划: 治理后端故障归位为 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 #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` - [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
@@ -96,7 +103,7 @@
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 25 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+6
View File
@@ -144,3 +144,9 @@
- [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 --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] 新增边: 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 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,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 47 条用例迁出真表,`_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/` 移植蓝本;且三项目当前不在工作区)。
+31 -2
View File
@@ -1,11 +1,11 @@
--- ---
type: schema type: schema
node_id: schema:llm-calls node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 25 字段)" title: "表结构: llm_calls(遥测 26 字段)"
date: 2026-07-20 date: 2026-07-20
--- ---
# 表结构: llm_calls(遥测 25 字段) # 表结构: llm_calls(遥测 26 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -31,6 +31,7 @@ date: 2026-07-20
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,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 | | 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`。见下方口径 | | 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 解耦) ## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -104,6 +105,34 @@ ORDER BY model, calls DESC;
三条限定各有理由: `cache_hit = false``cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。 三条限定各有理由: `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 铁律) ## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+14 -2
View File
@@ -22,16 +22,24 @@ from polygateway.errors import (
) )
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
from polygateway.pricing import ModelPrice, PricingTable 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.telemetry.schema import telemetry_schema_sql
from polygateway.thinking import ( from polygateway.thinking import (
ThinkingCapability, ThinkingCapability,
ThinkingResolution,
ThinkingUnsupportedError, ThinkingUnsupportedError,
get_capability, get_capability,
register_capability, register_capability,
resolve_thinking, resolve_thinking,
) )
from polygateway.types import ( from polygateway.types import (
EFFORT_ORDER,
Effort,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
OcrLayoutElement, OcrLayoutElement,
@@ -42,10 +50,12 @@ from polygateway.types import (
ThinkingObservation, ThinkingObservation,
) )
__version__ = "1.3.1" __version__ = "1.3.3"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted", "AllSourcesExhausted",
"CircuitOpenError", "CircuitOpenError",
"EmbeddingClient", "EmbeddingClient",
@@ -73,7 +83,9 @@ __all__ = [
"TelemetryStatus", "TelemetryStatus",
"ThinkingCapability", "ThinkingCapability",
"ThinkingObservation", "ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError", "ThinkingUnsupportedError",
"ThinkingWire",
"TransientError", "TransientError",
"__version__", "__version__",
"gather_bounded", "gather_bounded",
+53 -10
View File
@@ -34,12 +34,14 @@ from polygateway.sources import (
RoundRobinSelector, RoundRobinSelector,
SourceCooldownMemo, SourceCooldownMemo,
) )
from polygateway.thinking import get_capability, resolve_thinking from polygateway.thinking import effective_effort, get_capability, resolve_thinking
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
Effort,
LLMResponse, LLMResponse,
TelemetryStatus, TelemetryStatus,
coerce_effort,
validate_caller_dimensions, validate_caller_dimensions,
validate_request_overlay, validate_request_overlay,
) )
@@ -83,20 +85,36 @@ def _guard_thinking(
resolve_thinking( resolve_thinking(
profile, profile,
get_capability(source.model, table=capabilities), 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, model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback,
) )
def _fingerprint_mark(source: SourceConfig) -> str: def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。 """单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4 只在表态时追加不是省事: 这样只配了 `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)] parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None: if source.enable_thinking is not None:
parts.append(source.enable_thinking) 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) return json.dumps(parts, sort_keys=True, ensure_ascii=False)
@@ -104,16 +122,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。 """缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1 配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理 后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时 源级 `reasoning_effort`(issue #20)同理: 它一旦真正改变请求体,"关掉推理后
缓存的旧响应。全源者皆未表态时字面量与历史实现逐字相同,不触发存量 重启"就会读到开着推理时缓存的旧响应。全源者皆未表态时字面量与历史实现逐字
缓存冷启动。 相同,不触发存量缓存冷启动。
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
——后者由 `build_cache_key` 的 `reasoning_effort` 参数单独承担(ARCH §7.5)。
""" """
fingerprint = ",".join(sorted({s.model for s in sources})) 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( 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: if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest() digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
@@ -289,6 +316,7 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None, structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True, stream: bool = True,
overlay: Mapping[str, Any] | None = None, overlay: Mapping[str, Any] | None = None,
reasoning_effort: Effort | str | None = None,
tenant_id: str | None = None, tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None,
) -> LLMResponse: ) -> LLMResponse:
@@ -298,6 +326,11 @@ class GatewayClient:
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
参数不影响既有调用点(issue #4)。 参数不影响既有调用点(issue #4)。
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
与 `ENABLE_THINKING`(设计 §4.2)。`None` 是**不表态**(随源级配置),与
`Effort.NONE`("要求不推理")严格区分。裸字符串(`"low"`)也收,在此归一成
`Effort`,非法值当场 `ValueError`——与 `SourceConfig` 那条装配路同口径。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇 (租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。 (可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
@@ -317,6 +350,15 @@ class GatewayClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="chat(tenant_id=..., meta=...)" 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( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
@@ -327,6 +369,7 @@ class GatewayClient:
stream=stream, stream=stream,
overlay=sampling, overlay=sampling,
sampling=sampling, sampling=sampling,
reasoning_effort=effort,
tenant_id=dimension_tenant_id, tenant_id=dimension_tenant_id,
meta=dimensions, meta=dimensions,
) )
+10
View File
@@ -25,6 +25,7 @@ from polygateway.types import (
GlobalLimits, GlobalLimits,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
coerce_effort,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -43,6 +44,11 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"), "TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"), "INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
"ENABLE_THINKING": ("enable_thinking", "bool"), "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"), "MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"), "TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"), "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"): if lowered in ("0", "false", "no", "off"):
return False return False
raise ValueError(f"非法布尔值: {raw!r}") raise ValueError(f"非法布尔值: {raw!r}")
if kind == "effort":
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
return coerce_effort(raw, origin="")
if kind == "json": if kind == "json":
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装 # JSONDecodeError 是 ValueError 子类,复用下方的统一包装
parsed = json.loads(raw) parsed = json.loads(raw)
+3
View File
@@ -411,6 +411,9 @@ class EmbeddingClient:
latency_ms=int((self._now() - started) * 1000), latency_ms=int((self._now() - started) * 1000),
response=response, response=response,
error=None if error is None else str(error), 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: def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
+40 -1
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from polygateway.types import ChatRequest, LLMResponse, ThinkingObservation from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
@@ -53,6 +53,29 @@ def _coerce_observation(raw: Any) -> ThinkingObservation:
return ThinkingObservation.UNKNOWN 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]]: def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。 """多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
@@ -83,12 +106,21 @@ def build_cache_key(
salt: str | None, salt: str | None,
*, *,
sampling: Mapping[str, Any] | None = None, sampling: Mapping[str, Any] | None = None,
reasoning_effort: Effort | None = None,
) -> str: ) -> str:
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。 """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
`sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的 `sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的
salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个 salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。 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] = { key_obj: dict[str, Any] = {
"model": model_fingerprint, "model": model_fingerprint,
@@ -99,6 +131,8 @@ def build_cache_key(
key_obj["salt"] = salt key_obj["salt"] = salt
if sampling: if sampling:
key_obj["sampling"] = dict(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) payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest() return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -139,6 +173,9 @@ class CacheMW:
namespace, namespace,
request.cache_salt, request.cache_salt,
sampling=request.sampling, sampling=request.sampling,
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
reasoning_effort=request.reasoning_effort,
) )
cached = await self._safe_get(key) cached = await self._safe_get(key)
if cached is not None: if cached is not None:
@@ -162,6 +199,8 @@ class CacheMW:
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值 # 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields: if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"]) fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
fields.update( fields.update(
cache_hit=True, cache_hit=True,
latency_ms=0, latency_ms=0,
+8
View File
@@ -285,6 +285,9 @@ class RetryMW:
stream=request.stream, stream=request.stream,
overlay=request.overlay, overlay=request.overlay,
call_id=call_id, call_id=call_id,
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
# 自己按选中的源解析,两者在 effective_effort 里汇合)
reasoning_effort=request.reasoning_effort,
) )
if result.usage_source == "unavailable": if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回, # 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
@@ -392,6 +395,9 @@ class RetryMW:
reasoning_tokens=result.reasoning_tokens, reasoning_tokens=result.reasoning_tokens,
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判 # 裁定归 transport(它才见得到原始信号),本层只搬运不改判
thinking_observation=result.thinking_observation, thinking_observation=result.thinking_observation,
# 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉),
# 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档
applied_effort=result.applied_effort,
) )
async def _emit( async def _emit(
@@ -415,6 +421,8 @@ class RetryMW:
latency_ms=int((self._now() - started) * 1000), latency_ms=int((self._now() - started) * 1000),
response=response, response=response,
error=None if error is None else str(error), error=None if error is None else str(error),
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
reasoning_applies=True,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+90 -3
View File
@@ -23,7 +23,8 @@ from polygateway.errors import (
SourceNotConfiguredError, SourceNotConfiguredError,
) )
from polygateway.middleware.cache import digest_messages from polygateway.middleware.cache import digest_messages
from polygateway.types import ThinkingObservation, 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: if TYPE_CHECKING:
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
@@ -80,6 +81,70 @@ def _normalize_observation(raw: object) -> str:
return ThinkingObservation.UNKNOWN.value 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: def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap: if cap is None or len(text) <= cap:
@@ -161,7 +226,7 @@ class _AttemptUsage:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。"""
def __init__( def __init__(
self, self,
@@ -192,8 +257,17 @@ class TelemetryEmitter:
latency_ms: int, latency_ms: int,
response: LLMResponse | None, response: LLMResponse | None,
error: str | None, error: str | None,
reasoning_applies: bool,
) -> None: ) -> 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) usage = _AttemptUsage.of(response)
await self._record( await self._record(
request=request, request=request,
@@ -219,6 +293,9 @@ class TelemetryEmitter:
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
meta=request.meta, 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: async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
@@ -254,6 +331,9 @@ class TelemetryEmitter:
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3) # 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
meta=request.meta, meta=request.meta,
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
reasoning_effort=_normalize_effort(request.reasoning_effort),
) )
async def emit_terminal_failure( async def emit_terminal_failure(
@@ -286,6 +366,8 @@ class TelemetryEmitter:
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的 # 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
meta=request.meta, meta=request.meta,
# 可能根本没选出源,故与 sampling 同样只取请求档
reasoning_effort=_normalize_effort(request.reasoning_effort),
) )
async def _record( async def _record(
@@ -317,6 +399,10 @@ class TelemetryEmitter:
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None, tenant_id: str | None,
meta: Mapping[str, Any], meta: Mapping[str, Any],
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
reasoning_effort: str | None,
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -370,6 +456,7 @@ class TelemetryEmitter:
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让 # 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
# Postgres 那一路悄悄少一列数据 # Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation), thinking_observation=_normalize_observation(thinking_observation),
reasoning_effort=reasoning_effort,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+2
View File
@@ -455,6 +455,8 @@ class OcrClient:
latency_ms=latency_ms, latency_ms=latency_ms,
response=response, response=response,
error=error_text, error=error_text,
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
reasoning_applies=False,
) )
@staticmethod @staticmethod
+17 -2
View File
@@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable
from .types import ( from .types import (
ChatRequest, ChatRequest,
Effort,
EmbeddingTransportResult, EmbeddingTransportResult,
LLMResponse, LLMResponse,
OcrLayoutResult, OcrLayoutResult,
@@ -36,7 +37,16 @@ class Middleware(Protocol):
@runtime_checkable @runtime_checkable
class Transport(Protocol): class Transport(Protocol):
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。""" """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
"""
async def complete( async def complete(
self, self,
@@ -46,6 +56,7 @@ class Transport(Protocol):
stream: bool, stream: bool,
overlay: dict[str, Any], overlay: dict[str, Any],
call_id: str, call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult: ... ) -> TransportResult: ...
@@ -260,7 +271,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。 """遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
@@ -270,6 +281,9 @@ class TelemetryRecorder(Protocol):
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value` `thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受, 的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。 遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
`reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value`
的裸 `str`,`None` 表示调用方没表态——它与 `'none'`(明确要求不推理)不可折叠。
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由 recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。
""" """
@@ -302,4 +316,5 @@ class TelemetryRecorder(Protocol):
tenant_id: str, tenant_id: str,
meta: str, meta: str,
thinking_observation: str, thinking_observation: str,
reasoning_effort: str | None,
) -> None: ... ) -> None: ...
+123 -42
View File
@@ -14,75 +14,156 @@ from types import MappingProxyType
from typing import Any from typing import Any
@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) @dataclass(frozen=True)
class ProviderProfile: class ProviderProfile:
"""单个 provider 的能力与差异声明。 """单个 provider 的能力与差异声明。
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为 `thinking` 声明推理参数的**形态**(按 provider 变,数年不变一次);
True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不 `strip_think_tags` 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>`` `supports_native_schema` 供 D14 阶梯选择原生 response_format。
标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。
两档各有三种取值,**语义互不重叠**(issue #5): 注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型支持哪些
档位属**能力**(按 model 变),见 `thinking.ThinkingCapability`。二者合一在
========== ========================================================== provider 级表达不了代际差异——glm-5.2 能关而 glm-5.3 不能,形态却完全相同。
``{...}`` 已知的注入片段
``{}`` 已知**无需注入**任何参数即处于该档
``None`` **未知**: 本库不知道该 provider 如何表达这一档
========== ==========================================================
`None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否
关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。
""" """
name: str name: str
thinking_on: Mapping[str, Any] | None thinking: ThinkingWire
thinking_off: Mapping[str, Any] | None
strip_think_tags: bool strip_think_tags: bool
supports_native_schema: bool = False 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( DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
{ {
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态) # 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
"qwen": ProviderProfile( "qwen": ProviderProfile(
name="qwen", name="qwen",
thinking_on={"enable_thinking": True}, thinking=ThinkingWire(
thinking_off={"enable_thinking": False}, off={"enable_thinking": False},
on_base={"enable_thinking": True},
# 百炼的深度控制是 `thinking_budget`(token 预算)而非档位;
# 预算型控制本库当前不支持(设计 §11 明确不做)
effort_key=None,
),
strip_think_tags=True, strip_think_tags=True,
), ),
# 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度,
# V4 一代两者并用(deepseek-v4-* 的档位见能力表)
"deepseek": ProviderProfile( "deepseek": ProviderProfile(
name="deepseek", name="deepseek",
thinking_on={"thinking": {"type": "enabled"}}, thinking=ThinkingWire(
thinking_off={"thinking": {"type": "disabled"}}, off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False, strip_think_tags=False,
), ),
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把 # issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给 # {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在 # 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能)
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider "zhipu": ProviderProfile(
"openai": ProviderProfile( name="zhipu",
name="openai", thinking=ThinkingWire(
thinking_on=None, off={"thinking": {"type": "disabled"}},
thinking_off=None, on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False, strip_think_tags=False,
), ),
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2), # kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态
# 2026-08-25 复测结论不变(findings 2026-08-25 §5);**直连官方端点未验证**。 "moonshot": ProviderProfile(
# 实测 enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens name="moonshot",
# 恒定等于基线 194),reasoning_effort 才是真开关——本片段的选型据此成立。 thinking=ThinkingWire(
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的 off={"thinking": {"type": "disabled"}},
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近 on_base={"thinking": {"type": "enabled"}},
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。 effort_key="reasoning_effort",
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段) ),
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( "minimax": ProviderProfile(
name="minimax", name="minimax",
thinking_on={"reasoning_effort": "medium"}, thinking=ThinkingWire(
thinking_off={"reasoning_effort": "none"}, 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, strip_think_tags=False,
), ),
} }
+11 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列" 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 + `DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 26 个 INSERT 字段 +
`created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。 `created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。 互不依赖(import-linter 契约执法)。
@@ -51,7 +51,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT thinking_observation TEXT,
reasoning_effort TEXT
); );
""" """
@@ -82,7 +83,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb, meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT thinking_observation TEXT,
reasoning_effort TEXT
); );
""" """
@@ -100,6 +102,9 @@ SQLITE_BACKFILL = (
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事", # 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同 # 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
("thinking_observation", "TEXT"), ("thinking_observation", "TEXT"),
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"),
) )
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -114,6 +119,7 @@ _PG_BACKFILL_DECLS = (
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"), ("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
# 可空,理由同 SQLITE_BACKFILL 同名项 # 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"), ("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"),
) )
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -151,6 +157,7 @@ COLUMNS = (
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation", "thinking_observation",
"reasoning_effort",
) )
_COLUMN_SET = frozenset(COLUMNS) _COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 25 字段冻结签名(ports.TelemetryRecorder)。 """写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。 占位符同序——两者必须一起改,分开改就是把值写进错位的列。
+571 -59
View File
@@ -14,8 +14,8 @@ from typing import Any
from loguru import logger from loguru import logger
from polygateway.providers import ProviderProfile from polygateway.providers import ProviderProfile, ThinkingWire
from polygateway.types import ThinkingObservation from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation: def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
@@ -54,50 +54,314 @@ class ThinkingUnsupportedError(ValueError):
@dataclass(frozen=True) @dataclass(frozen=True)
class ThinkingCapability: class ThinkingCapability:
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。 """某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变, 与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者 数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的 合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。 M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
**档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了
"关不掉但能调到最低档"这第三种情况——而 GLM-5.3 系与 Gemini 3 Pro 都是它。
现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个
派生量一律不存字段,存了必与清单漂移。
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。 `evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
文档推定与实测必须在 evidence 里说清楚是哪种——前者会被 new-api 中转改写
(LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下三档、`perplexity/` 下六档)。
""" """
can_disable: bool supported_efforts: tuple[Effort, ...]
evidence: str 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( DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{ {
# —— MiniMax ——
"MiniMax-M3": ThinkingCapability( "MiniMax-M3": ThinkingCapability(
can_disable=True, supported_efforts=(
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=( evidence=(
"2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变;" f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理,"
"2026-08-25 复测依然成立(prompt 194 = 基线、completion 3、无推理正文)。" "completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 "
"两条限制(findings 2026-08-25-thinking-observability-regression §3.1/§5): " "全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
"① 非流式路径观测不到推理信号——推理已计费,但正文与 usage 明细都不回传;" "high 62-128 / xhigh 58-118 / max 57-170)——**它们是''的六种写法,不是六个深度档**,"
"② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关" "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( "MiniMax-M2.7": ThinkingCapability(
can_disable=False, supported_efforts=(Effort.AUTO,),
evidence=( evidence=(
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} " f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符),"
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段" "**关不掉**成立;auto 档 5/5 观测到推理。"
f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} "
"各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段"
), ),
), ),
"MiniMax-M2.5": ThinkingCapability( "MiniMax-M2.5": ThinkingCapability(
can_disable=False, supported_efforts=(Effort.AUTO,),
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理", evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;"
"auto 档 5/5 观测到推理(rt 121-245)。"
f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理"
),
), ),
# —— qwen(百炼系,开关型) ——
"qwen3.7-plus": ThinkingCapability( "qwen3.7-plus": ThinkingCapability(
can_disable=True, supported_efforts=(Effort.NONE, Effort.AUTO),
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)", 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( "deepseek-v4-pro": ThinkingCapability(
can_disable=True, supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)", 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 轮失败,连默认档基线都没取到,两源分歧仍悬着"
),
), ),
} }
) )
@@ -127,67 +391,302 @@ def register_capability(
return table 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( def resolve_thinking(
profile: ProviderProfile, profile: ProviderProfile,
capability: ThinkingCapability | None, capability: ThinkingCapability | None,
enable_thinking: bool | None, effort: Effort | str | None,
*, *,
model: str, model: str,
fallback: str = "error",
warn_unregistered: bool = True, warn_unregistered: bool = True,
) -> Mapping[str, Any]: ) -> ThinkingResolution:
"""三态 + 两层能力 请求体注入片段;不可满足时 ValueError。 """档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为 调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时 `RequestRejectedError`(四分类之一)。**判定顺序即语义,不可调换**:
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。 ========== ================================================================
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` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而 `model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
`capability` 为 None(未登记)时无从从别处取得模型名。 `capability` 为 None(未登记)时无从从别处取得模型名。
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次 `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 1: 调用方不表态 —— 与 False 严格区分,用模型默认档 # Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if enable_thinking is None: if effort is not None:
return {} effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
slot = profile.thinking_on if enable_thinking else profile.thinking_off # Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档
direction = "thinking_on" if enable_thinking else "thinking_off" if effort is None:
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方 return ThinkingResolution({}, None)
if slot is None: wire = profile.thinking
# Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方
if _wire_unknown_for(wire, effort):
raise ThinkingUnsupportedError( raise ThinkingUnsupportedError(
f"provider {profile.name!r} {direction} 形态未知(模型 {model!r}): " f"provider {profile.name!r}推理形态未知(模型 {model!r},请求档位 "
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态," f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 "
f"或改用 SourceConfig.extra_body 直接下发供应商参数" f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数"
) )
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功 # Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
if capability is None: if capability is None:
payload = _inject(profile, effort, model=model)
if warn_unregistered: if warn_unregistered:
_warn_unregistered(model, profile, slot) _warn_unregistered(model, profile, effort, payload)
return slot return ThinkingResolution(payload, effort)
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说 # Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须
if enable_thinking is False and not capability.can_disable: # 带一条能立刻照做的替代(见 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( raise ThinkingUnsupportedError(
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: " _tier_unsupported(model, effort, capability, fallback=fallback)
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
f"需要关闭思维链请换用支持关闭的模型"
) )
return slot
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
logger.warning( logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};" "模型 {} 不支持 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 登记", "若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
model, model,
profile.name, profile.name,
dict(slot), dict(payload),
effort.value,
) )
def reconcile_thinking( def reconcile_thinking(
*, *,
enable_thinking: bool | None, effort: Effort | None,
observation: ThinkingObservation, observation: ThinkingObservation,
capability: ThinkingCapability | None, capability: ThinkingCapability | None,
model: str, model: str,
@@ -197,6 +696,17 @@ def reconcile_thinking(
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的 能力表过期是必然事件(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 里。 而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。
@@ -205,24 +715,26 @@ def reconcile_thinking(
与遥测落地,处置权归下游。 与遥测落地,处置权归下游。
""" """
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背" # Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
if enable_thinking is None: if effort is None:
return None return None
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警 # Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警 # 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
if enable_thinking is False: if effort is Effort.NONE:
if observation is not ThinkingObservation.OBSERVED: if observation is not ThinkingObservation.OBSERVED:
return None return None
return _off_but_observed(model, capability) return _off_but_observed(model, capability)
# Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混 # Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是
# "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档
# 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题
if observation is ThinkingObservation.ABSENT: if observation is ThinkingObservation.ABSENT:
return ( return (
f"模型 {model!r}enable_thinking=True 未生效: 已注入开启参数," f"模型 {model!r}reasoning_effort={effort.value!r} 未生效: 已注入开启参数,"
f"上游却明确上报本次未推理(reasoning_tokens=0)" f"上游却明确上报本次未推理(reasoning_tokens=0)"
) )
if observation is ThinkingObservation.UNKNOWN: if observation is ThinkingObservation.UNKNOWN:
return ( return (
f"模型 {model!r}enable_thinking=True 无法确认是否生效: 已注入开启参数," f"模型 {model!r}reasoning_effort={effort.value!r} 无法确认是否生效: "
f"但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。" f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。"
f"若走的是非流式路径,推理内容可能已计费却不回传" f"若走的是非流式路径,推理内容可能已计费却不回传"
) )
return None return None
@@ -236,12 +748,12 @@ def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str:
""" """
if capability is None: if capability is None:
return ( return (
f"模型 {model!r}enable_thinking=False 未被满足: 实测观测到推理发生," f"模型 {model!r}reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。" f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
f"请实测后用 register_capability 登记其真实能力" f"请实测后用 register_capability 登记其真实能力"
) )
return ( return (
f"模型 {model!r}enable_thinking=False 未被满足: 实测观测到推理发生," f"模型 {model!r}reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。" f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
f"能力表可能已过期——请复测后用 register_capability 更新登记" f"能力表可能已过期——请复测后用 register_capability 更新登记"
) )
+66 -24
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
import re import re
import time import time
from dataclasses import replace
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import httpx import httpx
@@ -28,13 +29,19 @@ from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_ti
from polygateway.thinking import ( from polygateway.thinking import (
ThinkingCapability, ThinkingCapability,
ThinkingUnsupportedError, ThinkingUnsupportedError,
effective_effort,
get_capability, get_capability,
observe_thinking, observe_thinking,
reconcile_thinking, reconcile_thinking,
resolve_thinking, resolve_thinking,
) )
from polygateway.transports._http_errors import compose_message, summarize_body 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: if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping from collections.abc import AsyncIterator, Callable, Mapping
@@ -327,7 +334,7 @@ class OpenAICompatTransport:
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略, # 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制; # 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞) # 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, bool | None]] = set() self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {} self._clients: dict[str, httpx.AsyncClient] = {}
@@ -346,7 +353,14 @@ class OpenAICompatTransport:
profile: ProviderProfile, profile: ProviderProfile,
stream: bool, stream: bool,
overlay: dict[str, Any], 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} payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
if stream: if stream:
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款) payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
@@ -355,20 +369,28 @@ class OpenAICompatTransport:
capability = get_capability(source.model, table=self._capabilities) capability = get_capability(source.model, table=self._capabilities)
first_time = source.model not in self._warned_models first_time = source.model not in self._warned_models
self._warned_models.add(source.model) self._warned_models.add(source.model)
payload.update( # 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
resolve_thinking( # 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
profile, resolution = resolve_thinking(
capability, profile,
source.enable_thinking, capability,
model=source.model, effective_effort(
warn_unregistered=first_time, 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 在前,调用级 # 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换 # overlay(含结构化注入)在后覆盖之。两行不可调换
payload.update(source.extra_body) payload.update(source.extra_body)
payload.update(overlay) payload.update(overlay)
return payload return payload, resolution.applied_effort
async def complete( async def complete(
self, self,
@@ -378,12 +400,22 @@ class OpenAICompatTransport:
stream: bool, stream: bool,
overlay: dict[str, Any], overlay: dict[str, Any],
call_id: str, call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult: ) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。""" """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
`reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
"""
profile = get_provider(source.provider, registry=self._registry) profile = get_provider(source.provider, registry=self._registry)
try: try:
payload = self._build_payload( payload, applied_effort = self._build_payload(
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay messages=messages,
source=source,
profile=profile,
stream=stream,
overlay=overlay,
reasoning_effort=reasoning_effort,
) )
except ThinkingUnsupportedError as exc: except ThinkingUnsupportedError as exc:
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个 # 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
@@ -408,27 +440,37 @@ class OpenAICompatTransport:
except httpx.TransportError as exc: except httpx.TransportError as exc:
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8) # VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc 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) self._warn_on_thinking_mismatch(source, result)
return result return result
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None: def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
"""声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。 """声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。
三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**: 三段缺一不可。**档位**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个
多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是 键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过
独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出 那个字段,于是同一模型的所有档共用一个键)。**源名**: 多源多账号是本库的
问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于 核心场景,同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,一个
没有告警。 源的行为不代表另一个——漏掉源名,5 个源里第一个出问题的喊完一次,其余四个
永久静音。逐次调用刷屏会把告警变成噪声,噪声等于没有告警。
档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest`
映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"——拿一个从未发出
过的档去对账,文案与键都指向了一次不存在的请求。被映射到同一档的两个请求
因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)。
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串, **先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。 而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。
""" """
key = (source.name, source.model, source.enable_thinking) key = (source.name, source.model, result.applied_effort)
if key in self._warned_mismatches: if key in self._warned_mismatches:
return return
message = reconcile_thinking( message = reconcile_thinking(
enable_thinking=source.enable_thinking, effort=result.applied_effort,
observation=result.thinking_observation, observation=result.thinking_observation,
capability=get_capability(source.model, table=self._capabilities), capability=get_capability(source.model, table=self._capabilities),
model=source.model, model=source.model,
+183
View File
@@ -18,6 +18,9 @@ from loguru import logger
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"}) _MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType( _PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
{ {
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算", "model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
@@ -167,6 +170,89 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False) 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-M3、glm-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): class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。 """一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
@@ -240,6 +326,16 @@ class LLMResponse:
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。 实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`。
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)。""" 要判"确实没推理"只认 `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) @dataclass(frozen=True)
class ChatRequest: class ChatRequest:
@@ -275,6 +371,17 @@ class ChatRequest:
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非 再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。""" 语义维度,同 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) @dataclass(frozen=True)
class Usage: class Usage:
@@ -341,6 +448,13 @@ class TransportResult:
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默 默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
时,不该替上游做出"没推理"这个它从未做过的声明。""" 时,不该替上游做出"没推理"这个它从未做过的声明。"""
applied_effort: Effort | None = None
"""本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。
只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class SourceConfig: class SourceConfig:
@@ -348,6 +462,10 @@ class SourceConfig:
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、 限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)、
True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。 True=注入开启参数、False=注入关闭参数(统一 VT 与 CHS 相反的现状)。
2026-09-04 起 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
(`True`→`AUTO`、`False`→`NONE`),保留不删是因为它已被三项目消费
(迁移兼容约束,ARCH §5.1)。两个字段说的是同一件事,故矛盾即报错。
""" """
name: str name: str
@@ -372,10 +490,29 @@ class SourceConfig:
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法; (加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。""" 要可变副本用 `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: def __post_init__(self) -> None:
self._validate_identity() self._validate_identity()
self._validate_gates() self._validate_gates()
self._validate_watchdog() self._validate_watchdog()
self._validate_thinking()
self._freeze_extra_body() self._freeze_extra_body()
def effective_est_tokens(self) -> int: def effective_est_tokens(self) -> int:
@@ -413,6 +550,52 @@ class SourceConfig:
): ):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") 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: def _freeze_extra_body(self) -> None:
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。""" """校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
validated = validate_request_overlay( validated = validate_request_overlay(
+554 -1
View File
@@ -26,12 +26,15 @@
源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。
""" """
import asyncio
import dataclasses import dataclasses
import json import json
import os import os
from collections import Counter from collections import Counter
from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from types import MappingProxyType
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
@@ -39,11 +42,13 @@ from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings, ThinkingObservation from polygateway import GatewayClient, GatewaySettings, ThinkingObservation
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
GatewayUnavailableError,
RequestRejectedError, RequestRejectedError,
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.thinking 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} _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) _HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
@@ -77,7 +82,28 @@ _MODEL_PROVIDER = {
"MiniMax-M2.7": "minimax", "MiniMax-M2.7": "minimax",
"MiniMax-M2.5": "minimax", "MiniMax-M2.5": "minimax",
"qwen3.7-plus": "qwen", "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-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",
} }
@@ -514,7 +540,534 @@ class TestAssemblyGuardAgainstRealConfig:
stream=True, stream=True,
overlay={}, overlay={},
call_id="e2e-guard", call_id="e2e-guard",
reasoning_effort=None,
) )
finally: finally:
await client.aclose() await client.aclose()
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类") _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 官方文档说 HIGH、OpenRouter 说 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))
+216 -277
View File
@@ -1,11 +1,15 @@
"""PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。 """PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。
DSN .env `PGW_TELEMETRY_PG_DSN`,缺则 skip该实例上有 app/chs_prod DSN .env `PGW_TELEMETRY_PG_DSN`,缺则 skip该实例上有 app/chs_prod
在用库本测试只允许连 polygateway 专用库(fixture 里守卫) 在用库本测试只允许连 polygateway 专用库(`conftest.py` 的工厂里守卫)
隔离纪律(M4 事故教训): `llm_calls` 是与真实批跑/迁移项目共享的表, 隔离纪律(issue #18): 本文件对共享表 `llm_calls` **零触碰**——每条用例都在
**严禁 DROP/TRUNCATE**本测试以 run call_id 前缀隔离,断言只看 `pg_sandbox` 建的一次性 schema 里跑,/删都只发生在自己的 schema
自己写入的行,teardown 只删自己的行 此前那套 run call_id 前缀隔离已随之删除: schema 隔离完全取代了它,
两套并存只会让"这一行归谁"重新变成需要论证的事
**唯一的例外是连接**: 连接是实例级共享资源,schema 隔离对它无效,
`TestPoolFootprint` 仍靠一个就地生成的唯一 `application_name` 认领本池连接
""" """
from __future__ import annotations from __future__ import annotations
@@ -53,17 +57,12 @@ _EXPECTED_COLUMNS = [
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation", "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: def _dsn() -> str | None:
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
merged = {**dotenv_values(".env"), **os.environ} merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN") raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw: if not raw:
@@ -73,23 +72,24 @@ def _dsn() -> str | None:
@pytest.fixture @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() value = _dsn()
if value is None: if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置") pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库 # 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"): if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}") pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
yield value return 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()
async def _record_minimal( async def _record_minimal(
@@ -101,7 +101,7 @@ async def _record_minimal(
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证 "库写错列位"的形态误报,而漏抄的列则悄悄不被验证
""" """
fields: dict[str, object] = { 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, "parent_call_id": None,
"session_id": "sess-1", "session_id": "sess-1",
"model": "m", "model": "m",
@@ -128,6 +128,8 @@ async def _record_minimal(
"meta": "{}", "meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str # 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown", "thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -176,8 +178,9 @@ async def _execute_script(dsn: str, sql: str) -> None:
await conn.close() await conn.close()
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_LEGACY_DDL = """ _LEGACY_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY, call_id TEXT PRIMARY KEY,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -202,56 +205,48 @@ CREATE TABLE {schema}.llm_calls (
@pytest.fixture @pytest.fixture
async def legacy_schema(dsn): async def legacy_schema(pg_sandbox) -> tuple[str, str]:
"""**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。 """一次性沙箱 schema 里造一张 18 列旧表,验证补列(issue #3)。
绝不碰共享 public.llm_calls: search_path recorder 指向临时 schema, 共享 `llm_calls` 一个字节都不碰: recorder search_path 指向沙箱 schema,
teardown DROP 自己建的 schema 清理由工厂统一兜底
""" """
import asyncpg sandbox = await pg_sandbox(ddl=_LEGACY_DDL)
return sandbox.dsn, sandbox.schema
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()
class TestObservabilityColumns: class TestObservabilityColumns:
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。""" """issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
async def test_values_round_trip(self, dsn): async def test_values_round_trip(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) await _record_minimal(recorder, call_id="hit", cached_prompt_tokens=64)
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0) await _record_minimal(recorder, call_id="zero", cached_prompt_tokens=0)
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01") await _record_minimal(recorder, call_id="model", model_reported="MiniMax-01")
await _record_minimal( 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( rows = await _fetch(
dsn, sandbox.dsn,
"SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls " "SELECT call_id, cached_prompt_tokens, model_reported, sampling, "
"WHERE call_id LIKE $1", "reasoning_effort FROM llm_calls WHERE call_id = ANY($1::text[])",
f"{_RUN_PREFIX}-%", ["hit", "zero", "model", "samp", "tier"],
) )
by_id = {r["call_id"]: r for r in rows} by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64 assert by_id["hit"]["cached_prompt_tokens"] == 64
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL assert by_id["zero"]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
assert by_id[_cid("model")]["cached_prompt_tokens"] is None assert by_id["model"]["cached_prompt_tokens"] is None
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01" assert by_id["model"]["model_reported"] == "MiniMax-01"
# issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在) # issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在)
assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0} assert json.loads(by_id["samp"]["sampling"]) == {"seed": 42, "temperature": 0}
assert by_id[_cid("hit")]["sampling"] is None 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: finally:
await recorder.aclose() await recorder.aclose()
@@ -261,7 +256,7 @@ class TestObservabilityColumns:
recorder = _recorder(schema_dsn, auto_migrate=True) recorder = _recorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( 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( cols = await _fetch(
schema_dsn, schema_dsn,
@@ -274,7 +269,7 @@ class TestObservabilityColumns:
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1", "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") assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
finally: finally:
@@ -282,42 +277,44 @@ class TestObservabilityColumns:
class TestSchema: class TestSchema:
async def test_schema_has_frozen_columns_in_order(self, dsn): async def test_schema_has_frozen_columns_in_order(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder) await _record_minimal(recorder)
rows = await _fetch( rows = await _fetch(
dsn, sandbox.dsn,
# `table_schema = $1` 不可省: 不带它,库里任何一个残留 schema 下的同名表
# 都会把自己的列拼进结果,这条断言于是以"列数不符"的形态被别人的残留误伤
"SELECT column_name FROM information_schema.columns " "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 assert [r["column_name"] for r in rows] == _EXPECTED_COLUMNS
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_call_id_idempotent(self, dsn): async def test_call_id_idempotent(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("dup")) await _record_minimal(recorder, call_id="dup")
await _record_minimal(recorder, call_id=_cid("dup"), response="second") await _record_minimal(recorder, call_id="dup", response="second")
rows = await _fetch( 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 assert [r["response"] for r in rows] == ["ok"] # ON CONFLICT DO NOTHING
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_concurrent_writes_all_land(self, dsn): async def test_concurrent_writes_all_land(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await asyncio.gather( await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50)) # 沙箱 schema 里只有这一批行,故全表 COUNT 就是本用例写入的行数——
) # 前缀过滤在这里已无事可做(它当年存在只是为了从共享表里认领自己的行)
rows = await _fetch( rows = await _fetch(sandbox.dsn, "SELECT count(*) AS n FROM llm_calls")
dsn,
"SELECT count(*) AS n FROM llm_calls WHERE call_id LIKE $1",
f"{_RUN_PREFIX}-c%",
)
assert rows[0]["n"] == 50 assert rows[0]["n"] == 50
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -341,7 +338,7 @@ class TestDegradation:
"""服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。""" """服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。"""
recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True) recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
await _record_minimal(recorder) # 不抛 await _record_minimal(recorder) # 不抛
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛 await _record_minimal(recorder, call_id="c2") # 已降级短路,同样不抛
await recorder.aclose() await recorder.aclose()
async def test_refused_connection_cools_down_and_retries_after_cooldown(self): async def test_refused_connection_cools_down_and_retries_after_cooldown(self):
@@ -364,7 +361,7 @@ class TestDegradation:
now=clock, now=clock,
) )
try: try:
await _record_minimal(recorder, call_id=_cid("deg1")) await _record_minimal(recorder, call_id="deg1")
first = recorder.telemetry_status first = recorder.telemetry_status
# 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死, # 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死,
# 整进程从此一行遥测都不落、只有重启能恢复 # 整进程从此一行遥测都不落、只有重启能恢复
@@ -375,14 +372,14 @@ class TestDegradation:
assert "建表探测失败" in (first.reason or "") assert "建表探测失败" in (first.reason or "")
clock.advance(30.0) clock.advance(30.0)
await _record_minimal(recorder, call_id=_cid("deg2")) await _record_minimal(recorder, call_id="deg2")
mid = recorder.telemetry_status mid = recorder.telemetry_status
# 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路) # 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路)
assert mid.retry_after_s == pytest.approx(30.0) assert mid.retry_after_s == pytest.approx(30.0)
assert mid.dropped_rows == 2 assert mid.dropped_rows == 2
clock.advance(30.1) clock.advance(30.1)
await _record_minimal(recorder, call_id=_cid("deg3")) await _record_minimal(recorder, call_id="deg3")
after = recorder.telemetry_status after = recorder.telemetry_status
# 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈) # 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈)
assert after.retry_after_s == pytest.approx(60.0) assert after.retry_after_s == pytest.approx(60.0)
@@ -391,23 +388,25 @@ class TestDegradation:
finally: finally:
await recorder.aclose() 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 拒)→ 丢该行,后续行照常落库。""" """运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte") await _record_minimal(recorder, call_id="bad", response="nul\x00byte")
await _record_minimal(recorder, call_id=_cid("good")) await _record_minimal(recorder, call_id="good")
rows = await _fetch( rows = await _fetch(
dsn, sandbox.dsn,
"SELECT call_id FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id", "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: finally:
await recorder.aclose() await recorder.aclose()
async def test_aclose_idempotent(self, dsn): async def test_aclose_idempotent(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
await _record_minimal(recorder) await _record_minimal(recorder)
await recorder.aclose() await recorder.aclose()
await recorder.aclose() await recorder.aclose()
@@ -427,7 +426,7 @@ def _tagged(dsn: str, app_name: str) -> str:
async def _pool_backend_count(dsn: str, app_name: str) -> int: async def _pool_backend_count(dsn: str, app_name: str) -> int:
"""数**本池**在服务端的连接数(只读查询,不改实例任何状态)。 """数**本池**在服务端的连接数(只读查询,不改实例任何状态)。
只按 run 级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或 只按用例级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例 用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例
(CLAUDE.md §4.6)本查询自己那条连接走未打 tag DSN,故不会数到自己 (CLAUDE.md §4.6)本查询自己那条连接走未打 tag DSN,故不会数到自己
""" """
@@ -458,73 +457,53 @@ class TestPoolFootprint:
那么多连接"——两件事,只有真实 PG 能证后者。 那么多连接"——两件事,只有真实 PG 能证后者。
""" """
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, dsn): async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, pg_sandbox):
app_name = f"{_RUN_PREFIX}-pool" # run 级唯一,与并跑的其他运行互不可见 sandbox = await pg_sandbox()
recorder = _recorder(_tagged(dsn, app_name), auto_migrate=True) # `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: try:
# 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前 # 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前
# `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10 # `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 到返回,连接必然已建立(没建立就写不成功), # **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功),
# 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。 # 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。
# 故这是个确定值,不是"某一刻恰好的采样" # 故这是个确定值,不是"某一刻恰好的采样"
assert await _pool_backend_count(dsn, app_name) == 1 assert await _pool_backend_count(sandbox.dsn, app_name) == 1
await asyncio.gather( 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 不是凑数——它确保过滤条件真的命中了本池, # 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池,
# 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过 # 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过
assert 1 <= steady <= _POOL_MAX assert 1 <= steady <= _POOL_MAX
finally: finally:
await recorder.aclose() 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 删除;非任何真实凭据 _PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据
@pytest.fixture @pytest.fixture
async def least_privilege_dsn(dsn): async def least_privilege_dsn(pg_sandbox) -> tuple[str, str]:
"""临时 schema + 临时角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。 """一次性 schema + 独占角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
这是 issue #9 的现场——最小权限部署的标准形态。fixture 建的一切 这是 issue #9 的现场——最小权限部署的标准形态。`role="grantee"` 的语义恰是它:
(schema角色)都在 teardown 里删净,共享的 public.llm_calls 不受影响; 表由 admin 建好(属主不是应用账号),角色只拿到 `USAGE` 加表级 grants,
连不上或无权建角色(非超级用户) skip,不让 CI 假绿 唯独没有 `CREATE ON SCHEMA`缺的正是这一项
无权建角色(非超级用户)时工厂自己 skip,不让 CI 假绿
""" """
import asyncpg
from polygateway.telemetry.schema import PG_DDL from polygateway.telemetry.schema import PG_DDL
name = f"pgwtest_lp_{uuid4().hex[:8]}" sandbox = await pg_sandbox(ddl=PG_DDL, role="grantee")
admin = await asyncpg.connect(dsn, timeout=10) return sandbox.dsn, sandbox.schema
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()
class TestLeastPrivilegeDeployment: class TestLeastPrivilegeDeployment:
@@ -552,26 +531,28 @@ class TestLeastPrivilegeDeployment:
low_dsn, schema = least_privilege_dsn low_dsn, schema = least_privilege_dsn
recorder = _recorder(low_dsn, auto_migrate=True) recorder = _recorder(low_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lp1")) await _record_minimal(recorder, call_id="lp1")
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5) await _record_minimal(recorder, call_id="lp2", cost=1.5)
assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级 assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级
rows = await _fetch( rows = await _fetch(
low_dsn, low_dsn,
"SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id", "SELECT call_id, cost FROM llm_calls "
f"{_RUN_PREFIX}-lp%", "WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["lp1", "lp2"],
) )
assert [(r["call_id"], r["cost"]) for r in rows] == [ assert [(r["call_id"], r["cost"]) for r in rows] == [
(_cid("lp1"), None), ("lp1", None),
(_cid("lp2"), 1.5), ("lp2", 1.5),
] ]
assert schema # teardown 会连表带角色删净 assert schema # teardown 会连表带角色删净
finally: finally:
await recorder.aclose() 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 = """ _PRE_TENANT_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY, call_id TEXT PRIMARY KEY,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -598,18 +579,22 @@ CREATE TABLE {schema}.llm_calls (
) )
""" """
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故这行历史数据的 call_id 直接内联成
# 字面量('old' 是本文件固定的测试常量,不是外部输入)。
_PRE_TENANT_INSERT = ( _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) " "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` 去掉此后新增的 # `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。 # 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。 # 去掉后的顺序与 DDL 逐字一致(这列在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [ _PRE_TENANT_COLUMNS = [
c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta", "thinking_observation") c
for c in _EXPECTED_COLUMNS
if c not in ("tenant_id", "meta", "thinking_observation", "reasoning_effort")
] ]
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个 # 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
@@ -637,82 +622,36 @@ async def captured_warnings():
@pytest.fixture @pytest.fixture
async def pre_tenant_schema(dsn): async def pre_tenant_schema(pg_sandbox) -> tuple[str, str]:
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。 """一次性沙箱里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
绝不碰共享 public.llm_calls本机那张表早已被 `_BACKFILL` 真实补过列, 共享 `llm_calls` 一个字节都不碰本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转schema 名带 uuid,可重复运行 指望它还是旧形态的测试第二次跑就会空转
""" """
import asyncpg sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, extra=(_PRE_TENANT_INSERT,))
return sandbox.dsn, sandbox.schema
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()
@pytest.fixture @pytest.fixture
async def fresh_schema(dsn): async def fresh_schema(pg_sandbox) -> tuple[str, str]:
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。""" """空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
import asyncpg sandbox = await pg_sandbox()
return sandbox.dsn, sandbox.schema
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()
@pytest.fixture @pytest.fixture
async def least_privilege_pre_tenant_dsn(dsn): async def least_privilege_pre_tenant_dsn(pg_sandbox) -> str:
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。 """22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
`least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去 (测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断) 并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)
"""
import asyncpg
name = f"pgwtest_lppre_{uuid4().hex[:8]}" `role="grantee"` 正是这个现场: 表由 admin 建好(属主不是应用账号),角色只拿到
admin = await asyncpg.connect(dsn, timeout=10) 表级 SELECT/INSERT
try: """
if not await admin.fetchval( sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, role="grantee")
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user" return sandbox.dsn
):
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()
class TestCallerDimensionsAcceptance: class TestCallerDimensionsAcceptance:
@@ -724,7 +663,7 @@ class TestCallerDimensionsAcceptance:
recorder = _recorder(fresh_dsn, auto_migrate=True) recorder = _recorder(fresh_dsn, auto_migrate=True)
try: try:
await _record_minimal( 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( cols = await _fetch(
fresh_dsn, fresh_dsn,
@@ -736,7 +675,7 @@ class TestCallerDimensionsAcceptance:
rows = await _fetch( rows = await _fetch(
fresh_dsn, fresh_dsn,
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1", "SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
_cid("dim"), "dim",
) )
assert rows[0]["tenant_id"] == "tenant-a" assert rows[0]["tenant_id"] == "tenant-a"
assert json.loads(rows[0]["meta"]) == {"batch": "b7"} assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
@@ -757,28 +696,26 @@ class TestCallerDimensionsAcceptance:
schema_dsn, schema = pre_tenant_schema schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=True) recorder = _recorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(recorder, call_id="new", tenant_id="tenant-a", meta='{"k": 1}')
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch( cols = await _fetch(
schema_dsn, schema_dsn,
"SELECT column_name FROM information_schema.columns " "SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 22 → 25 个 recorder 字段(加 created_at 共 26 个物理列),且新列追加在末尾 # 22 → 26 个 recorder 字段(加 created_at 共 27 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
"SELECT call_id, tenant_id, meta FROM llm_calls " "SELECT call_id, tenant_id, meta FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id", "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} by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("new")]["tenant_id"] == "tenant-a" assert by_id["new"]["tenant_id"] == "tenant-a"
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1} assert json.loads(by_id["new"]["meta"]) == {"k": 1}
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert by_id["old"]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id[_cid("old")]["meta"]) == {} assert json.loads(by_id["old"]["meta"]) == {}
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -809,7 +746,7 @@ class TestCallerDimensionsAcceptance:
""" """
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True) recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛 await _record_minimal(recorder, call_id="lpp1") # 不得抛
assert recorder.telemetry_status.degraded is False assert recorder.telemetry_status.degraded is False
assert any("补列失败" in m for m in captured_warnings) assert any("补列失败" in m for m in captured_warnings)
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据 # 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
@@ -821,8 +758,9 @@ class TestCallerDimensionsAcceptance:
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。 # issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) —— # PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。 # 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_PARTITIONED_DDL = """ _PARTITIONED_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT NOT NULL, call_id TEXT NOT NULL,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -847,14 +785,14 @@ CREATE TABLE {schema}.llm_calls (
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb, meta JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (call_id, created_at) PRIMARY KEY (call_id, created_at)
) PARTITION BY RANGE (created_at) ) PARTITION BY RANGE (created_at)
""" """
# 仍带 `.format`,但只为月份边界——表名两处都已是裸名,由 search_path 定位
_PARTITION_DDL = ( _PARTITION_DDL = (
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls " "CREATE TABLE llm_calls_current PARTITION OF llm_calls FOR VALUES FROM ('{start}') TO ('{end}')"
"FOR VALUES FROM ('{start}') TO ('{end}')"
) )
@@ -868,29 +806,18 @@ def _current_month_bounds() -> tuple[str, str]:
@pytest.fixture @pytest.fixture
async def partitioned_schema(dsn): async def partitioned_schema(pg_sandbox) -> tuple[str, str]:
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。 """一次性沙箱里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
legacy_schema 同款隔离: 绝不碰共享 public.llm_calls,teardown DROP `legacy_schema` 同款隔离: 共享 `llm_calls` 一个字节都不碰,工厂的
自己建的 schema(CASCADE 连分区一并删) `DROP SCHEMA ... CASCADE` 连分区子表一并删
""" """
import asyncpg
name = f"pgwtest_part_{uuid4().hex[:8]}"
start, end = _current_month_bounds() start, end = _current_month_bounds()
conn = await asyncpg.connect(dsn, timeout=10) sandbox = await pg_sandbox(
try: ddl=_PARTITIONED_DDL,
await conn.execute(f"CREATE SCHEMA {name}") extra=(_PARTITION_DDL.format(start=start, end=end),),
await conn.execute(_PARTITIONED_DDL.format(schema=name)) )
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end)) return sandbox.dsn, sandbox.schema
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()
class TestConflictTargetFreeInsert: class TestConflictTargetFreeInsert:
@@ -905,11 +832,11 @@ class TestConflictTargetFreeInsert:
fresh_dsn, _ = fresh_schema fresh_dsn, _ = fresh_schema
recorder = _recorder(fresh_dsn, auto_migrate=True) recorder = _recorder(fresh_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("nodup")) await _record_minimal(recorder, call_id="nodup")
await _record_minimal(recorder, call_id=_cid("nodup"), response="second") await _record_minimal(recorder, call_id="nodup", response="second")
assert [m for m in captured_warnings if "写入失败" in m] == [] assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch( 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"] # 首行胜出,写入幂等 assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
finally: finally:
@@ -926,14 +853,14 @@ class TestConflictTargetFreeInsert:
part_dsn, _ = partitioned_schema part_dsn, _ = partitioned_schema
recorder = _recorder(part_dsn, auto_migrate=True) recorder = _recorder(part_dsn, auto_migrate=True)
try: 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] == [] assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch( rows = await _fetch(
part_dsn, part_dsn,
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1", "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: finally:
await recorder.aclose() await recorder.aclose()
@@ -952,13 +879,13 @@ class TestManualSchemaModeAcceptance:
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的三维度静默不写。 """22 字段旧表 + manual: 列一个不加,行照常落库,缺的三维度静默不写。
`test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照: `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 26 之别 同一张表同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 27 之别
""" """
schema_dsn, schema = pre_tenant_schema schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=False) recorder = _recorder(schema_dsn, auto_migrate=False)
try: try:
recorded = await _record_minimal( 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( cols = await _fetch(
schema_dsn, schema_dsn,
@@ -971,7 +898,7 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS) names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch( 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 拒收 assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位 # 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
@@ -981,8 +908,11 @@ class TestManualSchemaModeAcceptance:
assert [m for m in captured_warnings if "补列失败" in m] == [] assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏 assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿 # 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿
assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -999,17 +929,20 @@ class TestManualSchemaModeAcceptance:
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False) recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
try: try:
recorded = await _record_minimal( 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 [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 assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复 assert len(notices) == 1 # 准备期一次,第二行不再重复
# 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿 # 逐字钉住个维度: 前缀断言会让将来漏进告警的新列照样绿
assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0] assert (
"以下维度不会被记录: tenant_id, meta, thinking_observation, reasoning_effort。"
in notices[0]
)
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名 # 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert ( assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0] "ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
@@ -1031,10 +964,10 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS) names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch( rows = await _fetch(
least_privilege_pre_tenant_dsn, least_privilege_pre_tenant_dsn,
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id", f"SELECT {names} FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
f"{_RUN_PREFIX}-manlp%", ["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 dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
assert rows[1]["cost"] == 2.5 assert rows[1]["cost"] == 2.5
finally: finally:
@@ -1067,7 +1000,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 25 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 # 物理列 = 26 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"} assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS assert actual == _EXPECTED_COLUMNS
@@ -1184,14 +1117,20 @@ async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -
@pytest.fixture @pytest.fixture
async def production_template(dsn): async def production_template(template_admin_dsn):
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。 """在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
隔离纪律(M4 事故教训) `least_privilege_dsn`: 共享的 `public.llm_calls` **有意不收敛到 `pg_sandbox`**(设计 §7.1 末段): 它要建三个角色 README
解析出的整套模板 SQL按月建分区,权限语义与失败期清理都是它自己的,工厂
强行接管会把这些语义压扁故它是本文件唯一仍持管理连接的 fixture
隔离纪律(M4 事故教训) `least_privilege_dsn`: 共享表 `llm_calls`
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净 一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净
""" """
import asyncpg import asyncpg
dsn = template_admin_dsn
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
schema = f"pgwtpl_{suffix}" schema = f"pgwtpl_{suffix}"
roles = { roles = {
@@ -1206,7 +1145,7 @@ async def production_template(dsn):
f"README 的模板锚点与预期不符: {list(blocks)}" f"README 的模板锚点与预期不符: {list(blocks)}"
) )
seeded = (_cid("tpl-a"), _cid("tpl-b")) seeded = ("tpl-a", "tpl-b")
admin_dsn = _search_path_dsn(dsn, schema) admin_dsn = _search_path_dsn(dsn, schema)
admin = await asyncpg.connect(dsn, timeout=10) admin = await asyncpg.connect(dsn, timeout=10)
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException, # 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
@@ -1228,9 +1167,9 @@ async def production_template(dsn):
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True): for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
await admin.execute(_TEMPLATE_INSERT, call_id, tenant) await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
except BaseException: except BaseException:
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls, # 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,而三个
# `TestSchema` 那条 table_name 查 information_schema 的用例不带 # 角色是**全局**对象,不随库消失。`TestSchema` 那条用例如今自带 table_schema
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤 # 过滤已不再受残留影响,但残留本身仍是这个共享实例上的垃圾,该清还是要清。
await admin.close() await admin.close()
await _drop_template_objects(dsn, schema, roles) await _drop_template_objects(dsn, schema, roles)
raise raise
@@ -1303,17 +1242,17 @@ class TestProductionTemplate:
env = production_template env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10) conn = await asyncpg.connect(env.app_dsn, timeout=10)
try: 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): 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): with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("UPDATE llm_calls SET response = 'x'") await conn.execute("UPDATE llm_calls SET response = 'x'")
finally: finally:
await conn.close() await conn.close()
rows = await _fetch( 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): async def test_report_can_read_but_cannot_write(self, production_template):
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。""" """报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
@@ -1323,7 +1262,7 @@ class TestProductionTemplate:
conn = await asyncpg.connect(env.report_dsn, timeout=10) conn = await asyncpg.connect(env.report_dsn, timeout=10)
try: try:
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError): 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(): async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)") await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls") rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
@@ -73,7 +73,7 @@ class ScriptedTransport:
self.hang = hang self.hang = hang
self.calls: list[str] = [] 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) self.calls.append(source.name)
if self.hang: if self.hang:
await asyncio.Event().wait() await asyncio.Event().wait()
+209 -106
View File
@@ -1,11 +1,12 @@
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。 """`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 from __future__ import annotations
@@ -16,10 +17,8 @@ import subprocess
import sys import sys
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from dotenv import dotenv_values
from polygateway.telemetry.schema import PG_DDL 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: def _stamp(delta: timedelta) -> datetime:
return datetime.now(UTC) + delta 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: async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
import asyncpg import asyncpg
@@ -142,50 +90,31 @@ async def _call_ids(schema_dsn: str) -> list[str]:
return [r["call_id"] for r in rows] 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 @pytest.fixture
async def partitioned_schema(dsn): async def partitioned_sandbox(pg_sandbox):
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。""" """临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
name = await _make_schema( return await pg_sandbox(
dsn, ddl=_partitioned_ddl(),
"part",
_partitioned_ddl(),
extra=( extra=(
"CREATE TABLE llm_calls_all PARTITION OF llm_calls " "CREATE TABLE llm_calls_all PARTITION OF llm_calls "
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')", "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 @pytest.fixture
async def plain_schema(dsn): async def plain_sandbox(pg_sandbox):
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。""" """临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
name = await _make_schema(dsn, "plain", PG_DDL) return await pg_sandbox(ddl=PG_DDL, role="owner")
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
class TestPartitionedTarget: class TestPartitionedTarget:
async def test_partitioned_table_exits_three_without_deleting_anything( async def test_partitioned_table_exits_three_without_deleting_anything(
self, partitioned_schema self, partitioned_sandbox
): ):
schema_dsn, schema = partitioned_schema
await _seed( await _seed(
schema_dsn, partitioned_sandbox.dsn,
[ [
("part-old-1", "", _stamp(timedelta(days=-30))), ("part-old-1", "", _stamp(timedelta(days=-30))),
("part-old-2", "acme", _stamp(timedelta(days=-20))), ("part-old-2", "acme", _stamp(timedelta(days=-20))),
@@ -194,24 +123,28 @@ class TestPartitionedTarget:
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住 # 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
result = _run( 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) assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined assert "DROP PARTITION" in combined
assert "DETACH" 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: class TestPlainTableBatches:
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn): async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
schema_dsn, schema = plain_schema
before_public = await _public_count(dsn)
await _seed( await _seed(
schema_dsn, plain_sandbox.dsn,
[ [
("old-1", "", _stamp(timedelta(days=-40))), ("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))), ("old-2", "acme", _stamp(timedelta(days=-30))),
@@ -227,7 +160,7 @@ class TestPlainTableBatches:
"--backend", "--backend",
"postgres", "postgres",
"--dsn", "--dsn",
schema_dsn, plain_sandbox.dsn,
"--older-than-days", "--older-than-days",
"7", "7",
"--apply", "--apply",
@@ -236,8 +169,8 @@ class TestPlainTableBatches:
) )
assert result.returncode == 0, (result.stdout, result.stderr) assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"] assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{schema}.llm_calls" in result.stdout assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout assert "'acme': 3" in result.stdout
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条 # 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
@@ -245,30 +178,200 @@ class TestPlainTableBatches:
assert "批次 3" in result.stdout assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout assert "批次 4" not in result.stdout
assert "已删除 5 行" 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): async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
schema_dsn, _ = plain_schema await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
await _seed(schema_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 result.returncode == 0, (result.stdout, result.stderr)
assert "将删除行数: 1" in result.stdout assert "将删除行数: 1" in result.stdout
assert "dry-run" 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: 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),不静默降级——这是运维工具不是库路径。 """缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该 用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
场景: 脚本跑在子进程里,monkeypatch 对它无效DSN **真实可连**的临时 schema, 场景: 脚本跑在子进程里,monkeypatch 对它无效DSN **真实可连**的临时 schema,
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿 这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿
""" """
schema_dsn, _ = plain_schema await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
stub = tmp_path / "stub" stub = tmp_path / "stub"
stub.mkdir() stub.mkdir()
(stub / "asyncpg.py").write_text( (stub / "asyncpg.py").write_text(
@@ -285,7 +388,7 @@ class TestMissingAsyncpg:
"--backend", "--backend",
"postgres", "postgres",
"--dsn", "--dsn",
schema_dsn, plain_sandbox.dsn,
"--older-than-days", "--older-than-days",
"7", "7",
"--apply", "--apply",
@@ -295,4 +398,4 @@ class TestMissingAsyncpg:
assert result.returncode == 2, (result.stdout, result.stderr) assert result.returncode == 2, (result.stdout, result.stderr)
assert "asyncpg" in result.stderr assert "asyncpg" in result.stderr
assert "pip install" 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.clock = clock
self.calls = [] 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)) self.calls.append((source.name, call_id))
advance, action = self.script.pop(0) advance, action = self.script.pop(0)
self.clock.advance(advance) self.clock.advance(advance)
+142 -1
View File
@@ -11,7 +11,13 @@ from polygateway.backends.memory.cache import InMemoryCache
from polygateway.errors import ResultInvalidError, TransientError from polygateway.errors import ResultInvalidError, TransientError
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
SourceConfig,
ThinkingObservation,
)
_MSGS = [{"role": "user", "content": "hi"}] _MSGS = [{"role": "user", "content": "hi"}]
@@ -114,6 +120,62 @@ class TestKeyFormula:
"m", messages2, "p", None "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: class _Terminal:
def __init__(self, response): def __init__(self, response):
@@ -163,6 +225,21 @@ class TestCacheFlow:
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal) third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
assert third.cache_hit is True and terminal.calls == 2 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): async def test_structured_injection_does_not_pollute_key(self):
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。""" """CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
backend = InMemoryCache() backend = InMemoryCache()
@@ -324,6 +401,69 @@ class TestThinkingObservationRehydration:
assert hit.thinking_observation is ThinkingObservation.UNKNOWN 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: class _BrokenBackend:
async def get(self, key): async def get(self, key):
raise ConnectionError("redis down") raise ConnectionError("redis down")
@@ -444,6 +584,7 @@ class TestTelemetryCapDoesNotPoisonTheCacheKey:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
# 截断确实发生了(否则本用例恒真) # 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"]) logged = json.loads(rec.rows[0]["messages"])
+297
View File
@@ -12,6 +12,7 @@ from polygateway import (
AllSourcesExhausted, AllSourcesExhausted,
GatewayClient, GatewayClient,
GatewaySettings, GatewaySettings,
RequestRejectedError,
gather_bounded, gather_bounded,
) )
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
@@ -24,6 +25,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
Effort,
GlobalLimits, GlobalLimits,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
@@ -198,6 +200,246 @@ class TestSamplingOverlay:
assert [c["seed"] for c in captured] == [1, 2] 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: class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。""" """收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
@@ -324,6 +566,61 @@ class TestModelFingerprint:
b = build_model_fingerprint([_source(extra_body={"temperature": 1})]) b = build_model_fingerprint([_source(extra_body={"temperature": 1})])
assert a != b 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: class TestFactories:
def test_from_env_assembles(self): 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.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.types import Effort, SourceConfig
_BASE_ENV = { _BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1", "LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
@@ -132,6 +134,113 @@ class TestExtraBodyParsing:
GatewaySettings.from_env("LLM", env=env) 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: class TestResilienceKeys:
def test_flat_legacy_keys(self): def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env()) s = GatewaySettings.from_env("LLM", env=_env())
@@ -637,10 +746,41 @@ class TestCrossFieldInvariants:
GatewayClient.from_settings(settings) GatewayClient.from_settings(settings)
def test_unknown_thinking_shape_fails_at_assembly(self): def test_unknown_thinking_shape_fails_at_assembly(self):
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。""" # 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发
settings = self._thinking_sources("openai", "kimi-k3", False) # 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"): 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): def test_supported_combination_assembles(self):
settings = self._thinking_sources("minimax", "MiniMax-M3", False) settings = self._thinking_sources("minimax", "MiniMax-M3", False)
+92 -12
View File
@@ -16,13 +16,14 @@ from polygateway.errors import (
) )
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable 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._http_errors import summarize_body
from polygateway.transports.openai_compat import ( from polygateway.transports.openai_compat import (
OpenAICompatTransport, OpenAICompatTransport,
_iter_sse_deltas, _iter_sse_deltas,
_sse_data_payload, _sse_data_payload,
) )
from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
def _source(**overrides): 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"}) 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) mock = httpx.MockTransport(handler)
return OpenAICompatTransport( 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( return await transport.complete(
messages=[{"role": "user", "content": "hi"}], messages=[{"role": "user", "content": "hi"}],
source=source, source=source,
stream=stream, stream=stream,
overlay=overlay or {}, overlay=overlay or {},
call_id="cid-1", call_id="cid-1",
reasoning_effort=reasoning_effort,
) )
@@ -120,6 +123,7 @@ async def _recorded_cost(result, source):
latency_ms=1, latency_ms=1,
response=response, response=response,
error=None, error=None,
reasoning_applies=True,
) )
return recorder.rows[0]["cost"] return recorder.rows[0]["cost"]
@@ -626,6 +630,60 @@ class TestThinkingReconciliation:
hits = [m for m in messages if "MiniMax-M3" in m] hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)}" 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: class TestNonStreamFastPath:
async def test_non_stream_parses_message(self): async def test_non_stream_parses_message(self):
@@ -666,6 +724,10 @@ class TestRequestShaping:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("enable_thinking", "expected"), ("enable_thinking", "expected"),
# 本条断言反复过一次,记下原委以免第三次改回去:
# T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入;
# T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理,
# 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束)
[(True, "medium"), (False, "none")], [(True, "medium"), (False, "none")],
) )
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected): async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
@@ -680,11 +742,20 @@ class TestRequestShaping:
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
) )
await _complete(_transport_for(handler), source) await _complete(_transport_for(handler), source)
assert seen["reasoning_effort"] == expected if expected is None:
assert "reasoning_effort" not in seen
else:
assert seen["reasoning_effort"] == expected
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发 assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
async def test_extra_body_overrides_the_profile_slot(self): 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 = {} seen = {}
def handler(request): def handler(request):
@@ -692,14 +763,17 @@ class TestRequestShaping:
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE)) return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source( source = _source(
name="mm", name="zp",
provider="minimax", provider="zhipu",
model="MiniMax-M3", model="glm-5.3",
enable_thinking=True, reasoning_effort="low",
extra_body={"reasoning_effort": "high"}, extra_body={"reasoning_effort": "high"},
) )
await _complete(_transport_for(handler), source) await _complete(_transport_for(handler), source)
# profile 注入的是 low,extra_body 后写故发出去的是 high;顺序一调换就变 low,
# 即下游写在 extra_body 里的覆盖被库悄悄顶掉(issue #20 的成因形态)
assert seen["reasoning_effort"] == "high" assert seen["reasoning_effort"] == "high"
assert seen["thinking"] == {"type": "enabled"} # profile 独有的那半边仍在
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self): async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。 """M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
@@ -758,9 +832,15 @@ class TestRequestShaping:
def handler(request): # pragma: no cover - 不该走到发请求 def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出") 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"): 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): async def test_overlay_merged_into_payload(self):
seen = {} seen = {}
+15 -1
View File
@@ -60,12 +60,15 @@ def test_thinking_public_surface_exported():
`observe_thinking` / `reconcile_thinking` ****导出: 它们是 transport 内部 `observe_thinking` / `reconcile_thinking` ****导出: 它们是 transport 内部
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份 的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
永久承诺 永久承诺`ThinkingResolution` ****导出它是已导出的 `resolve_thinking`
的返回类型,不导出等于下游拿得到实例却写不出类型标注
""" """
for name in ( for name in (
"ThinkingCapability", "ThinkingCapability",
"ThinkingObservation", "ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError", "ThinkingUnsupportedError",
"ThinkingWire",
"get_capability", "get_capability",
"register_capability", "register_capability",
"resolve_thinking", "resolve_thinking",
@@ -74,3 +77,14 @@ def test_thinking_public_surface_exported():
assert name in polygateway.__all__, name assert name in polygateway.__all__, name
assert "observe_thinking" not in polygateway.__all__ assert "observe_thinking" not in polygateway.__all__
assert "reconcile_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 快照校验。""" """ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
import inspect
from typing import Any from typing import Any
import pytest import pytest
from polygateway.ports import ( from polygateway.ports import (
CacheBackend, CacheBackend,
EmbeddingTransport,
GateDecision, GateDecision,
GateState, GateState,
GateUpdate, GateUpdate,
Middleware, Middleware,
OcrTransport,
Permit, Permit,
ProviderGate, ProviderGate,
RateLimiter, RateLimiter,
@@ -69,7 +72,7 @@ class _DummyMw:
class _DummyTransport: 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 raise NotImplementedError
@@ -142,6 +145,34 @@ def test_protocols_are_runtime_checkable(impl, protocol):
assert isinstance(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): class _DummyStatusProvider(_DummyRecorder):
@property @property
def telemetry_status(self) -> TelemetryStatus: def telemetry_status(self) -> TelemetryStatus:
@@ -244,7 +275,9 @@ class TestTelemetryRecorderSignature:
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params) assert {"tenant_id", "meta"} <= set(params)
@pytest.mark.parametrize("name", ["tenant_id", "meta", "thinking_observation"]) @pytest.mark.parametrize(
"name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"]
)
def test_caller_dimensions_have_no_default(self, name): def test_caller_dimensions_have_no_default(self, name):
import inspect import inspect
+4
View File
@@ -177,6 +177,7 @@ class TestEmitterCost:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] == pytest.approx(7.2) assert rec.rows[0]["cost"] == pytest.approx(7.2)
@@ -196,6 +197,7 @@ class TestEmitterCost:
latency_ms=1, latency_ms=1,
response=None, response=None,
error="TransientError: boom", error="TransientError: boom",
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
@@ -209,6 +211,7 @@ class TestEmitterCost:
latency_ms=1, latency_ms=1,
response=_resp(model="mystery"), response=_resp(model="mystery"),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
@@ -223,5 +226,6 @@ class TestEmitterCost:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
+95 -32
View File
@@ -1,57 +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 import pytest
from polygateway.providers import ( from polygateway.providers import (
DEFAULT_PROFILES, DEFAULT_PROFILES,
ProviderProfile, ProviderProfile,
ThinkingWire,
get_provider, get_provider,
register_provider, register_provider,
) )
_EXPECTED_SEGMENTS = frozenset(
{"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"}
)
class TestDefaultProfiles: class TestDefaultProfiles:
def test_qwen_profile(self): def test_all_eight_profiles_registered(self):
p = get_provider("qwen") """issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。"""
assert p.thinking_on == {"enable_thinking": True} assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS
assert p.thinking_off == {"enable_thinking": False}
assert p.strip_think_tags is True
assert p.supports_native_schema is False
def test_deepseek_profile(self): def test_qwen_is_a_switch_with_no_tiers(self):
p = get_provider("deepseek") w = get_provider("qwen").thinking
assert p.thinking_on == {"thinking": {"type": "enabled"}} assert w.on_base == {"enable_thinking": True}
assert p.thinking_off == {"thinking": {"type": "disabled"}} assert w.off == {"enable_thinking": False}
assert p.strip_think_tags is 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): def test_deepseek_carries_both_switch_and_tier(self):
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此), 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") for name in ("openai", "anthropic", "google"):
assert p.thinking_on is None and p.thinking_off is None w = get_provider(name).thinking
assert p.strip_think_tags is False 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): def test_minimax_on_tier_carries_a_tier_value(self):
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。""" """issue #21 的权宜之计: minimax 的""必须真写一个档位值,不能是空片段。
p = get_provider("minimax")
assert p.thinking_off == {"reasoning_effort": "none"} 断言反复过一次: T2 "这些模型默认就推理"的推定把它改成 `{}`,T10 真实
assert p.thinking_on == {"reasoning_effort": "medium"} 网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium
assert p.strip_think_tags is False """
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): def test_unknown_provider_fails_loudly(self):
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。""" """消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
with pytest.raises(ValueError, match="glm"): with pytest.raises(ValueError, match="glm"):
get_provider("glm") get_provider("glm") # 段名是 zhipu,不是 glm
with pytest.raises(ValueError): with pytest.raises(ValueError):
get_provider("qwen2") # 子串相似也不放行 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: class TestPureFunctionRegistration:
def test_register_returns_new_mapping(self): 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) table = register_provider(glm)
assert get_provider("glm", registry=table) is glm assert get_provider("glm", registry=table) is glm
# 默认表未被污染(无可变全局状态铁律) # 默认表未被污染(无可变全局状态铁律)
@@ -59,12 +120,14 @@ class TestPureFunctionRegistration:
get_provider("glm") get_provider("glm")
def test_register_on_custom_base_and_override(self): def test_register_on_custom_base_and_override(self):
custom_qwen = ProviderProfile( custom = ProviderProfile(
name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False name="qwen",
thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None),
strip_think_tags=False,
) )
table = register_provider(custom_qwen, base=DEFAULT_PROFILES) table = register_provider(custom, base=DEFAULT_PROFILES)
assert get_provider("qwen", registry=table).thinking_on == {"x": 1} assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1}
assert get_provider("qwen").thinking_on == {"enable_thinking": True} assert get_provider("qwen").thinking.on_base == {"enable_thinking": True}
def test_default_profiles_mapping_is_read_only(self): def test_default_profiles_mapping_is_read_only(self):
with pytest.raises(TypeError): with pytest.raises(TypeError):
+160
View File
@@ -261,6 +261,157 @@ class TestUsageErrors:
assert result.returncode == 1 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: class TestHelp:
def test_help_names_the_maintenance_role_and_the_recommended_path(self): def test_help_names_the_maintenance_role_and_the_recommended_path(self):
@@ -271,3 +422,12 @@ class TestHelp:
assert "维护角色" in result.stdout assert "维护角色" in result.stdout
assert "REVOKE" in result.stdout assert "REVOKE" in result.stdout
assert "PARTITION" 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
+68 -2
View File
@@ -24,6 +24,7 @@ from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
ChatRequest, ChatRequest,
Effort,
GlobalLimits, GlobalLimits,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
@@ -63,14 +64,21 @@ def _ok(content="ok"):
class FakeTransport: class FakeTransport:
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)。""" """按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位
`reasoning_effort` 刻意**不给默认值**, `Transport` 协议保持逐字一致:
`@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传"
这类缺口伪装成"调用方没表态",而报错现场离根因很远
"""
def __init__(self, script): def __init__(self, script):
self.script = list(script) self.script = list(script)
self.calls = [] 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.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort)
action = self.script.pop(0) action = self.script.pop(0)
if isinstance(action, Exception): if isinstance(action, Exception):
raise action raise action
@@ -135,6 +143,37 @@ def _harness(
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}]) _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: class TestSuccessPath:
async def test_first_attempt_success_builds_response(self): async def test_first_attempt_success_builds_response(self):
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")]) mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
@@ -249,6 +288,33 @@ class TestObservabilityPassthrough:
resp = await mw(_REQ) resp = await mw(_REQ)
assert resp.thinking_observation is ThinkingObservation.UNKNOWN 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: class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self): async def test_transient_switches_source_then_succeeds(self):
+259 -15
View File
@@ -25,6 +25,7 @@ from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
ChatRequest, ChatRequest,
Effort,
EmbeddingTransportResult, EmbeddingTransportResult,
GlobalLimits, GlobalLimits,
LLMResponse, LLMResponse,
@@ -63,6 +64,7 @@ _EXPECTED_COLUMNS = [
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation", "thinking_observation",
"reasoning_effort",
] ]
@@ -132,6 +134,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
"meta": "{}", "meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str # 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown", "thinking_observation": "unknown",
# 同理: `Effort` 归一成裸 str,不表态则是 None(与 'low' 必须分得开)
"reasoning_effort": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -177,16 +181,17 @@ _FROZEN_SQLITE_INSERT = (
"INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, " "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, " "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, " "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) " "model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation, "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" "reasoning_effort) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
) )
_FROZEN_PG_INSERT = ( _FROZEN_PG_INSERT = (
"INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, " "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, " "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, " "ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, "
"sampling, reasoning_tokens, tenant_id, meta, thinking_observation) " "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, " "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, $25) " "$19, $20, $21, $22, $23, $24, $25, $26) "
# 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、 # 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、
# 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入 # 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入
"ON CONFLICT DO NOTHING" "ON CONFLICT DO NOTHING"
@@ -212,7 +217,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at # COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"] assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 25 assert len(COLUMNS) == 26
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位) # 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL): for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -226,8 +231,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
) )
assert PG_BACKFILL[-1] == ( assert PG_BACKFILL[-1] == (
"thinking_observation", "reasoning_effort",
"ALTER TABLE llm_calls ADD COLUMN thinking_observation TEXT", "ALTER TABLE llm_calls ADD COLUMN reasoning_effort TEXT",
) )
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL) assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -275,7 +280,7 @@ class TestSchemaModule:
pg = telemetry_schema_sql("postgres") pg = telemetry_schema_sql("postgres")
lite = telemetry_schema_sql("sqlite") lite = telemetry_schema_sql("sqlite")
for script in (pg, lite): for script in (pg, lite):
# 25 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致 # 26 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
assert "CREATE TABLE IF NOT EXISTS llm_calls" in script assert "CREATE TABLE IF NOT EXISTS llm_calls" in script
# 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同) # 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同)
@@ -313,7 +318,7 @@ class TestBackendColumnParity:
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-3:] == ("tenant_id", "meta", "thinking_observation") assert COLUMNS[-4:] == ("tenant_id", "meta", "thinking_observation", "reasoning_effort")
class TestSQLiteRecorder: class TestSQLiteRecorder:
@@ -405,6 +410,27 @@ class TestSQLiteRecorder:
assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事 assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事
assert rows["t-unknown"] == "unknown" 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): async def test_sampling_column_round_trips(self, tmp_path):
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。""" """issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True) recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
@@ -568,7 +594,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 25 个 recorder 字段(+ created_at 共 26 物理列) assert cols == _EXPECTED_COLUMNS # 22 → 26 个 recorder 字段(+ created_at 共 27 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall()) rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a" assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -610,7 +636,7 @@ class TestSQLiteCallerDimensionsAcceptance:
stale = sqlite3.connect(db) stale = sqlite3.connect(db)
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == ( assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
_EXPECTED_COLUMNS[:-3] _EXPECTED_COLUMNS[:-4]
) # 补列确实没成功,用例不是在只读库上空转 ) # 补列确实没成功,用例不是在只读库上空转
@@ -618,7 +644,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**: 旧表 22 INSERT 字段 + `created_at` = 23, 列数断言一律按**物理列数**: 旧表 22 INSERT 字段 + `created_at` = 23,
补齐后 25 + `created_at` = 26混用 INSERT 字段数与物理列数是本处最易错的地方 补齐后 26 + `created_at` = 27混用 INSERT 字段数与物理列数是本处最易错的地方
""" """
def _physical_columns(self, db: Path) -> list[str]: def _physical_columns(self, db: Path) -> list[str]:
@@ -657,7 +683,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path): async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 26""" """auto + 同款旧表: 现状回归,补列后物理列数 23 → 27"""
db = tmp_path / "auto_legacy.db" db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db) _make_pre_tenant_db(db)
@@ -666,10 +692,10 @@ class TestSQLiteSchemaMode:
recorder.close() recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 26 assert len(self._physical_columns(db)) == 27
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path): async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,26 个物理列齐全(设计 §4.2)。""" """manual 只管 ALTER,不管 CREATE: 全新库照建,27 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db" db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False) recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -894,6 +920,7 @@ class TestPostgresBackfillDiscipline:
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation", "thinking_observation",
"reasoning_effort",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -1120,6 +1147,7 @@ class TestEmitterRecorderContract:
latency_ms=42, latency_ms=42,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
assert set(rec.rows[0]) == set(COLUMNS) assert set(rec.rows[0]) == set(COLUMNS)
@@ -1137,6 +1165,7 @@ class TestEmitterRecorderContract:
latency_ms=1, latency_ms=1,
response=None, response=None,
error="boom", error="boom",
reasoning_applies=True,
) )
elif emit == "cache_hit": elif emit == "cache_hit":
await emitter.emit_cache_hit(request=_REQ, response=_resp()) await emitter.emit_cache_hit(request=_REQ, response=_resp())
@@ -1165,6 +1194,7 @@ class TestEmitterThinkingObservation:
latency_ms=1, latency_ms=1,
response=_resp(thinking_observation=ThinkingObservation.OBSERVED), response=_resp(thinking_observation=ThinkingObservation.OBSERVED),
error=None, error=None,
reasoning_applies=True,
) )
value = rec.rows[0]["thinking_observation"] value = rec.rows[0]["thinking_observation"]
assert value == "observed" assert value == "observed"
@@ -1187,6 +1217,7 @@ class TestEmitterThinkingObservation:
latency_ms=1, latency_ms=1,
response=_resp(thinking_observation="observed"), response=_resp(thinking_observation="observed"),
error=None, error=None,
reasoning_applies=True,
) )
assert len(rec.rows) == 1, "整行被吞了" assert len(rec.rows) == 1, "整行被吞了"
value = rec.rows[0]["thinking_observation"] value = rec.rows[0]["thinking_observation"]
@@ -1212,6 +1243,7 @@ class TestEmitterThinkingObservation:
latency_ms=1, latency_ms=1,
response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外 response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外
error=None, error=None,
reasoning_applies=True,
) )
finally: finally:
logger.remove(sink_id) logger.remove(sink_id)
@@ -1250,10 +1282,205 @@ class TestEmitterThinkingObservation:
latency_ms=1, latency_ms=1,
response=None, response=None,
error="boom", error="boom",
reasoning_applies=True,
) )
assert rec.rows[0]["thinking_observation"] == "unknown" 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: class TestEmitterObservabilityFields:
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。""" """issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
@@ -1266,6 +1493,7 @@ class TestEmitterObservabilityFields:
latency_ms=42, latency_ms=42,
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7), response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cached_prompt_tokens"] == 64 assert rec.rows[0]["cached_prompt_tokens"] == 64
assert rec.rows[0]["model_reported"] == "m-real" assert rec.rows[0]["model_reported"] == "m-real"
@@ -1280,6 +1508,7 @@ class TestEmitterObservabilityFields:
latency_ms=7, latency_ms=7,
response=None, response=None,
error="boom", error="boom",
reasoning_applies=True,
) )
assert rec.rows[0]["cached_prompt_tokens"] is None assert rec.rows[0]["cached_prompt_tokens"] is None
assert rec.rows[0]["model_reported"] is None assert rec.rows[0]["model_reported"] is None
@@ -1330,6 +1559,7 @@ class TestEmitterSamplingColumn:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0} assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0}
@@ -1344,6 +1574,7 @@ class TestEmitterSamplingColumn:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp()) await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp())
await emitter.emit_terminal_failure( await emitter.emit_terminal_failure(
@@ -1376,6 +1607,7 @@ class TestEmitterSamplingColumn:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["sampling"] is None assert rec.rows[0]["sampling"] is None
@@ -1408,6 +1640,7 @@ class TestEmitterCallerDimensions:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
elif emit == "cache_hit": elif emit == "cache_hit":
await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True)) await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True))
@@ -1461,6 +1694,7 @@ class TestEmitterCallerDimensions:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
row = rec.rows[0] row = rec.rows[0]
assert row["tenant_id"] == "" assert row["tenant_id"] == ""
@@ -1521,6 +1755,7 @@ class TestCostWithCachedTier:
latency_ms=1, latency_ms=1,
response=full, response=full,
error=None, error=None,
reasoning_applies=True,
) )
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
@@ -1531,6 +1766,7 @@ class TestCostWithCachedTier:
prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000 prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000
), ),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] == pytest.approx(10.0) assert rec.rows[0]["cost"] == pytest.approx(10.0)
assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2 assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2
@@ -1553,6 +1789,7 @@ class TestCostWithCachedTier:
latency_ms=1, latency_ms=1,
response=_resp(usage_source="unavailable", cached_prompt_tokens=5), response=_resp(usage_source="unavailable", cached_prompt_tokens=5),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
@@ -1568,6 +1805,7 @@ class TestEmitter:
latency_ms=42, latency_ms=42,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) )
row = rec.rows[0] row = rec.rows[0]
assert row["call_id"] == "cid-1" and row["error"] is None assert row["call_id"] == "cid-1" and row["error"] is None
@@ -1584,6 +1822,7 @@ class TestEmitter:
latency_ms=7, latency_ms=7,
response=None, response=None,
error="TransientError: boom", error="TransientError: boom",
reasoning_applies=True,
) )
row = rec.rows[0] row = rec.rows[0]
assert row["error"].startswith("TransientError") assert row["error"].startswith("TransientError")
@@ -1615,6 +1854,7 @@ class TestEmitter:
usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion
), ),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] is None assert rec.rows[0]["cost"] is None
@@ -1628,6 +1868,7 @@ class TestEmitter:
latency_ms=42, latency_ms=42,
response=_resp(prompt_tokens=0, completion_tokens=4000), response=_resp(prompt_tokens=0, completion_tokens=4000),
error=None, error=None,
reasoning_applies=True,
) )
assert rec.rows[0]["cost"] == pytest.approx(0.032) assert rec.rows[0]["cost"] == pytest.approx(0.032)
@@ -1661,6 +1902,7 @@ class TestEmitter:
latency_ms=1, latency_ms=1,
response=None, response=None,
error="x", error="x",
reasoning_applies=True,
) )
assert len(rec.rows[0]["messages"]) < 500 # base64 不整段进库(VT R12) assert len(rec.rows[0]["messages"]) < 500 # base64 不整段进库(VT R12)
@@ -1677,6 +1919,7 @@ class TestEmitter:
latency_ms=1, latency_ms=1,
response=_resp(), response=_resp(),
error=None, error=None,
reasoning_applies=True,
) # 不抛(降级不冒泡) ) # 不抛(降级不冒泡)
@@ -1773,6 +2016,7 @@ async def _emit_with_cap(messages, *, cap, response=_LONG, thinking=_LONG):
latency_ms=1, latency_ms=1,
response=_resp(content=response, thinking=thinking), response=_resp(content=response, thinking=thinking),
error=None, error=None,
reasoning_applies=True,
) )
return rec.rows[0] return rec.rows[0]
+558 -44
View File
@@ -8,17 +8,29 @@
import pytest import pytest
from loguru import logger from loguru import logger
from polygateway.providers import get_provider from polygateway.providers import ProviderProfile, ThinkingWire, get_provider
from polygateway.thinking import ( from polygateway.thinking import (
DEFAULT_CAPABILITIES, DEFAULT_CAPABILITIES,
ThinkingCapability, ThinkingCapability,
ThinkingUnsupportedError,
effective_effort,
get_capability, get_capability,
observe_thinking, observe_thinking,
reconcile_thinking, reconcile_thinking,
register_capability, register_capability,
resolve_thinking, resolve_thinking,
) )
from polygateway.types import ThinkingObservation 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(): def _warnings():
@@ -125,7 +137,7 @@ class TestThinkingCapability:
assert get_capability("some-brand-new-model") is None assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self): def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability(True, "实测")) 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", table=table) is not None
assert get_capability("x-1") is None # 默认表未被污染 assert get_capability("x-1") is None # 默认表未被污染
@@ -135,66 +147,377 @@ class TestThinkingCapability:
class TestResolveThinking: class TestResolveThinking:
"""条判定规则(顺序即语义);设计 §5 真值表。""" """道关卡(顺序即语义)与 nearest 映射;设计 §4.1。
def test_rule1_none_injects_nothing(self): 每一关都有独立的失败模式,漏测哪一关,判定顺序被调换都不会被抓住而顺序
在本函数里**就是**语义(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") got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got == {} assert got.payload == {}
assert got.applied_effort is None
@pytest.mark.parametrize("enable", [True, False]) # —— Phase 2: 形态未知 ——
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): @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() messages, sink_id = _warnings()
try: try:
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9") got = resolve_thinking(get_provider("minimax"), None, Effort.NONE, model="MiniMax-M9")
finally: finally:
logger.remove(sink_id) logger.remove(sink_id)
assert got == {"reasoning_effort": "none"} assert got.payload == {"reasoning_effort": "none"}
assert got.applied_effort is Effort.NONE
assert any("MiniMax-M9" in m for m in messages) assert any("MiniMax-M9" in m for m in messages)
def test_rule4_cannot_disable_raises_with_the_model_name(self): def test_phase3_can_be_silenced_on_the_hot_path(self):
cap = get_capability("MiniMax-M2.7") """装配期已经喊过一次,逐次调用再喊只会刷屏;判定结果不受影响。"""
with pytest.raises(ValueError, match="MiniMax-M2.7"): messages, sink_id = _warnings()
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7") 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_rule4_only_blocks_the_off_direction(self): def test_phase3_does_not_validate_tiers(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦""" """能力未知就没有清单可比对,拿空清单去拒绝档位等于凭空报错"""
cap = get_capability("MiniMax-M2.7") got = resolve_thinking(
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7") get_provider("zhipu"),
assert got == {"reasoning_effort": "medium"} 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
def test_rule5_normal_path(self): # —— 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") cap = get_capability("MiniMax-M3")
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == { got = resolve_thinking(get_provider("minimax"), cap, Effort.NONE, model="MiniMax-M3")
"reasoning_effort": "none" assert got.payload == {"reasoning_effort": "none"}
} assert got.applied_effort is Effort.NONE
def test_unknown_shape_beats_capability_check(self): # —— Phase 5: 档位打空 ——
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
cap = ThinkingCapability(can_disable=False, evidence="构造") def test_phase5_lists_tiers_for_tiered_model(self):
with pytest.raises(ValueError, match="register_provider"): """档位型模型: 文案必须列出它真有的档,否则下游只能猜。"""
resolve_thinking(get_provider("openai"), cap, False, model="whatever") 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: class TestReconcileThinking:
"""声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。 """声明 × 观测对账(设计 §4.3): 矛盾出文案,不表态出 None。
文案本身是被断言对象判定与日志分离正是为此: 告警内容可直接比对,不必 文案本身是被断言对象判定与日志分离正是为此: 告警内容可直接比对,不必
去解析日志格式 去解析日志格式
判据自 2026-09-05 起是**档位**而非布尔(设计 §4.3): `Effort.NONE` "要求
关闭"一支,其余档走"要求开启"一支。档位化不是换个参数名——文案里写的是本次
真正发出去的那一档, transport 的节流键正按它分离,两者必须同源
""" """
_CAP = ThinkingCapability( _CAP = ThinkingCapability(
can_disable=True, evidence="2026-08-02 实测 reasoning_effort=none 可关闭" (Effort.NONE, Effort.AUTO), "2026-08-02 实测 reasoning_effort=none 可关闭"
) )
def test_off_but_observed_with_a_registered_capability_blames_the_table(self): def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。""" """已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
msg = reconcile_thinking( msg = reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED, observation=ThinkingObservation.OBSERVED,
capability=self._CAP, capability=self._CAP,
model="MiniMax-M3", model="MiniMax-M3",
@@ -207,7 +530,7 @@ class TestReconcileThinking:
def test_off_but_observed_unregistered_never_claims_a_table_entry(self): def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。""" """未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
msg = reconcile_thinking( msg = reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED, observation=ThinkingObservation.OBSERVED,
capability=None, capability=None,
model="MiniMax-M9", model="MiniMax-M9",
@@ -219,13 +542,13 @@ class TestReconcileThinking:
def test_registered_and_unregistered_wordings_differ(self): def test_registered_and_unregistered_wordings_differ(self):
registered = reconcile_thinking( registered = reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED, observation=ThinkingObservation.OBSERVED,
capability=self._CAP, capability=self._CAP,
model="MiniMax-M3", model="MiniMax-M3",
) )
unregistered = reconcile_thinking( unregistered = reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.OBSERVED, observation=ThinkingObservation.OBSERVED,
capability=None, capability=None,
model="MiniMax-M3", model="MiniMax-M3",
@@ -236,7 +559,7 @@ class TestReconcileThinking:
def test_on_but_absent_is_a_contradiction(self, capability): def test_on_but_absent_is_a_contradiction(self, capability):
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。""" """上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
msg = reconcile_thinking( msg = reconcile_thinking(
enable_thinking=True, effort=Effort.AUTO,
observation=ThinkingObservation.ABSENT, observation=ThinkingObservation.ABSENT,
capability=capability, capability=capability,
model="qwen3.7-plus", model="qwen3.7-plus",
@@ -248,7 +571,7 @@ class TestReconcileThinking:
def test_on_but_unknown_admits_it_cannot_confirm(self, capability): def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果"""" """issue #17 的诚实版本: 明说"我注入了,但我看不见结果""""
msg = reconcile_thinking( msg = reconcile_thinking(
enable_thinking=True, effort=Effort.AUTO,
observation=ThinkingObservation.UNKNOWN, observation=ThinkingObservation.UNKNOWN,
capability=capability, capability=capability,
model="MiniMax-M3", model="MiniMax-M3",
@@ -265,7 +588,7 @@ class TestReconcileThinking:
""" """
assert ( assert (
reconcile_thinking( reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.ABSENT, observation=ThinkingObservation.ABSENT,
capability=self._CAP, capability=self._CAP,
model="qwen3.7-plus", model="qwen3.7-plus",
@@ -277,7 +600,7 @@ class TestReconcileThinking:
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。""" """UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
assert ( assert (
reconcile_thinking( reconcile_thinking(
enable_thinking=False, effort=Effort.NONE,
observation=ThinkingObservation.UNKNOWN, observation=ThinkingObservation.UNKNOWN,
capability=self._CAP, capability=self._CAP,
model="MiniMax-M3", model="MiniMax-M3",
@@ -293,7 +616,7 @@ class TestReconcileThinking:
"""调用方不表态,就无从谈"违背"""" """调用方不表态,就无从谈"违背""""
assert ( assert (
reconcile_thinking( reconcile_thinking(
enable_thinking=None, effort=None,
observation=observation, observation=observation,
capability=self._CAP, capability=self._CAP,
model="MiniMax-M3", model="MiniMax-M3",
@@ -304,10 +627,201 @@ class TestReconcileThinking:
def test_on_and_observed_is_exactly_what_was_asked_for(self): def test_on_and_observed_is_exactly_what_was_asked_for(self):
assert ( assert (
reconcile_thinking( reconcile_thinking(
enable_thinking=True, effort=Effort.AUTO,
observation=ThinkingObservation.OBSERVED, observation=ThinkingObservation.OBSERVED,
capability=self._CAP, capability=self._CAP,
model="MiniMax-M3", model="MiniMax-M3",
) )
is None 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
)
+44
View File
@@ -10,6 +10,7 @@ from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
ChatRequest, ChatRequest,
Effort,
GlobalLimits, GlobalLimits,
LLMResponse, LLMResponse,
RetryPolicy, RetryPolicy,
@@ -566,3 +567,46 @@ class TestChatRequestDimensions:
) )
assert request.tenant_id == "t1" assert request.tenant_id == "t1"
assert request.meta == {"batch": "b-42"} 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, stream=True,
overlay={}, overlay={},
call_id="cid", call_id="cid",
reasoning_effort=None,
) )
assert result.usage_source in USAGE_SOURCES assert result.usage_source in USAGE_SOURCES
@@ -261,6 +262,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
latency_ms=10, latency_ms=10,
response=_resp(emitted), response=_resp(emitted),
error=None, error=None,
reasoning_applies=True,
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES 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, latency_ms=10,
response=None, response=None,
error="boom", error="boom",
reasoning_applies=True,
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES 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.config import _SOURCE_FIELDS
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
from polygateway.providers import register_provider 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 里以别名出现的白名单(仅限确无歧义的自解释形参) # 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}} _PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
+83 -9
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import re
import sqlite3 import sqlite3
import sys import sys
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
TABLE = "llm_calls" TABLE = "llm_calls"
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则 # 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
EXIT_OK = 0 EXIT_OK = 0
EXIT_USAGE = 1 EXIT_USAGE = 1
@@ -59,6 +65,11 @@ _EPILOG = """\
时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行; 时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行;
--older-than-days 0 "删除此刻之前的全部行" --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 \\ python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
--older-than-days 90 # dry-run,只看会删什么 --older-than-days 90 # dry-run,只看会删什么
@@ -114,6 +125,11 @@ def _build_parser() -> _Parser:
action="store_true", action="store_true",
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给", help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
) )
parser.add_argument(
"--table",
metavar="SCHEMA.NAME",
help=f"仅 postgres: 把目标钉死为 <schema>.{TABLE},绕开 search_path 推断",
)
return parser return parser
@@ -150,10 +166,16 @@ def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
parser.error("--backend sqlite 不接受 --dsn") parser.error("--backend sqlite 不接受 --dsn")
if args.batch_size is not None: if args.batch_size is not None:
parser.error("--batch-size 仅用于 --backend postgres") 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: 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: if args.dsn is None:
parser.error("--backend postgres 需要 --dsn") parser.error("--backend postgres 需要 --dsn")
if args.path is not None: if args.path is not None:
@@ -164,6 +186,36 @@ def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
args.batch_size = 1000 args.batch_size = 1000
elif args.batch_size < 1: elif args.batch_size < 1:
parser.error("--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: 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}"' 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(每批一个事务)。""" """PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
try: try:
import asyncpg 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) print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
return EXIT_BACKEND return EXIT_BACKEND
try: 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: except asyncpg.PostgresError as exc:
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr) print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
return EXIT_BACKEND return EXIT_BACKEND
@@ -265,23 +319,41 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
await conn.close() 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 类型以免脚本硬依赖)。""" """已连上后的清理主体(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( target = await conn.fetchrow(
"SELECT n.nspname AS schema, c.relname AS name, " "SELECT n.nspname AS schema, c.relname AS name, "
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned " "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 " "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass($1)", "WHERE c.oid = to_regclass($1)",
TABLE, lookup,
) )
if target is None: if target is None:
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr) # 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
# 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 return EXIT_BACKEND
schema, name = target["schema"], target["name"] schema, name = target["schema"], target["name"]
qualified = f"{_quote(schema)}.{_quote(name)}" qualified = f"{_quote(schema)}.{_quote(name)}"
print(f"目标表: {schema}.{name}") print(f"目标表: {schema}.{name}")
if apply_ and table_schema is None:
# 只在 --apply 时提示: dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途。
print(
"注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。"
)
if target["partitioned"]: if target["partitioned"]:
print( print(
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n" 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(只统计,不删除)'}") print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
if args.backend == "sqlite": if args.backend == "sqlite":
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum) 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__": if __name__ == "__main__":