165 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The design turns that verdict into a first-class return value judged
from multiple signals, says UNKNOWN when a single response cannot tell,
and reconciles it against the capability table so a stale declaration
becomes a warning instead of a silent illusion.
2026-08-25 22:02:03 -04:00
iomgaa f5cf69a1ac Merge branch 'feat/issue-15-telemetry-pool-lifecycle' 2026-08-24 13:18:16 -04:00
iomgaa ef13ca7ea9 chore: cut 1.3.0 and date its changelog entry 2026-08-24 13:18:07 -04:00
iomgaa 8e66a362f7 docs: fix the callout counts the fourth entry invalidated
补进「请先读这一条(四)」之后,CHANGELOG:20 与设计 §7 仍写「三条/三处」,
同一份文档里出现自相矛盾的计数——正是本轮在消灭的那类失真。一并给设计
§7 补上第 4 条的正文,免得清单与 CHANGELOG 再次漂移。
2026-08-24 12:55:45 -04:00
iomgaa 15f0c16782 docs: correct three claims the code never made good on
1. 两个新遥测配置字段被 CHANGELOG 与 ARCHITECTURE 说成「带缺省」,实际是无
   默认值的必填字段(缺省只在 env 装配路 _load_*),且就地加默认值在 dataclass
   上根本不可能(后面跟着四个无默认值字段)。直接构造 GatewaySettings 的下游
   升级即 TypeError,这是真正的破坏性变更,补进 CHANGELOG 的「请先读这一条」。
2. ARCHITECTURE Q2 仍写最低 Python 3.11,按 2026-08-24 人类确认改 3.12,并
   记明原依据「覆盖三项目 3.11×2」已过时,三个迁移目标均已 ≥3.12。
3. 三分表里的 TimeoutError 只在准备期路径可达: 写入期的超时先被
   record_llm_call 的 except 顺序按行级丢弃,故「最坏成本每 60s 一次、上界一
   个预算」的承诺只在准备期成立。本次只改文档不改行为,连续超预算丢行是否
   升档留作后续议题。
2026-08-24 12:44:41 -04:00
iomgaa 28e0ea2442 fix: count every dropped SQLite telemetry row
SQLite 的逐行写入失败只发 warning、不计数,磁盘满 / database is locked /
文件被外部改坏时行真的丢了,而 dropped_rows 恒 0、degraded 恒 False——下游
按 README 的口径读快照对账完全看不见,与 issue #15 要消灭的静默失败同型。
同批修掉关闭后的丢行文案: 写死的遥测已降级与此时 degraded=False 的快照
互相矛盾,改为按状态分档(降级中 / 已关闭),与 PG 侧 _drop_reason 同口径。
2026-08-24 12:39:02 -04:00
iomgaa 1fb02a24e9 docs: fill in the T8 commit hashes in the plan 2026-08-24 11:58:39 -04:00
iomgaa 6d6b3cf59c docs: correct the stale throughput numbers and wiki state
独立验证发现的 3 处文档欠账:

③ 两处代码内注释还挂着已作废的吞吐估算,`.env.example`/README/
   CHANGELOG/ARCHITECTURE 四处早已改成实测口径:
   - `config.py` 的 `# 4 条 ≈ 32 行/秒(实测…)` —— "32 行/秒"正是设计
     §10 修订 #1 判定"偏乐观一倍"并作废的估算值,却挂着"实测"二字;
   - `postgres.py` 的 `pool_max` docstring 写着 `稳态吞吐 ≈ pool_max /
     RTT`,正是设计要求下游**不要**用的那个公式。
   两处统一为实测值: RTT ≈ 123ms 上 `pool_max=4` 约 15.6 行/秒
   (50 行并发批 3.2s)。设计 §8 与计划 T7 里残留的同一公式一并标注作废。

④ 文档写 `acquire(timeout=剩余预算)`,实现传的是完整预算(行为无害,
   外层 `asyncio.timeout` 才是真正上界)。**改文档不改代码**: 设计
   §3.1、计划 T3、ARCH §7.8 三处对齐,并写明为什么内层不再算剩余量。

⑤ wiki 登记页与正文状态漂移: design 登记页仍写"待人类审"(正文已是
   "已实施")、plan 登记页写"正文 326 行"(实际 380)、log.md 末条停在
   T0 之前。三处校正,T1-T8 补登记,rebuild_index。

另补一条独立验证在真实 PG 上发现的语义细节: 本地池饱和造成的丢行走
**行级丢弃**,`degraded` 保持 False,只有 `dropped_rows` 增长——只按
`degraded` 配告警的下游会完全看不见这类丢行,而它恰是 `pool_max` 配小
了的唯一信号。README / .env.example / ARCHITECTURE / CHANGELOG 各补一句。
2026-08-24 11:55:22 -04:00
iomgaa f90f7b036c test: give the log level and ownership rules real enforcement
两条"确证的假绿"(独立验证发现):

① 设计 §3.2 的"配置级致命发 error 而非 warning"没有执法点:
   `captured_warnings` fixture 挂在 level="WARNING",ERROR 与 WARNING
   同池,且 tracker 自己那条 WARNING 文案就含"重启"——把 recorder 的
   `logger.error` 整块删掉,原用例照样绿。新增 `captured_logs` fixture
   连级别一起捕获,三处补上级别断言。

   顺带消掉实现与设计的偏离: 原实现同时发 1 条 ERROR(recorder)+ 1 条
   语义重复的 WARNING(tracker)。级别决策收敛到 tracker 一处(fatal →
   error,其余 → warning),recorder 侧不再另发,SQLite 侧同时受益。

② 所有权判定的 `is None` / `is not None` 纪律(设计 §3.4)零覆盖:
   所有假件都是 truthy,把工厂改回 `limiter or _build_limiter(...)`
   全套件照样绿。补 `_FalsyClosable`(`__bool__` 返 False)与三个工厂
   各一条用例: 注入 falsy 后端时工厂不得自建、`_owns_*` 为 False、
   `aclose` 不得关它。
2026-08-24 11:45:32 -04:00
iomgaa 9026acd7dc docs: fill in the T7 commit hashes in the plan 2026-08-24 11:12:36 -04:00
iomgaa 4e1f09d231 docs: record the telemetry pool semantics and ownership rule
ARCHITECTURE 7.8 gains the pool resource semantics, the two-sentence
failure verdict and the two config keys; the ownership rule lands in a
new 4.5 because it is a cross-subsystem discipline, not a telemetry
convention. CHANGELOG leads with the three items downstream must read
first: the 3.12 floor, the connection count going from 10 per client to
on demand, and aclose no longer closing injected components.
2026-08-24 11:09:22 -04:00
iomgaa 7834d751d0 feat: export TelemetryStatus from the package root
client.telemetry_status exists so downstream can reconcile telemetry
programmatically, but annotating its return type meant reaching into
polygateway.types while the convention here is that the top-level
exports are the public API surface. The port itself stays unexported:
nobody outside the library implements it.
2026-08-24 11:06:22 -04:00
iomgaa 69a5b5fadb test: pin the cooldown assertion to a fake clock
The status snapshot reports elapsed time, so asserting retry_after_s
against the real monotonic clock was really asserting that a few lines
of code take zero time; it failed at 59.99993 vs 60.0. The recorder
already accepts an injected clock for exactly this reason.
2026-08-24 11:03:33 -04:00
iomgaa bfeda5b5e9 test: prove on real PG that the pool never preconnects
The min_size=10 default survived to 1.2.4 because every PG test injected a
pool and thus skipped the pool-building path entirely. Unit tests now assert
the create_pool arguments, but "we passed min_size=0" and "the server really
opened that many backends" are two different claims, and only a real instance
can settle the second one. Count via a run-unique application_name carried on
the DSN: the instance is shared with other projects, so counting by database
or role would fold their connections into ours and make the case flaky by
construction.

Degradation is exercised through an unreachable DSN rather than by exhausting
the shared instance's connections. A refused connection lands in the same
class as exhaustion, and the fake clock lets the 60s cooldown be observed
without sleeping. retry_after_s is the signal that separates a real retry
(which renews the window) from the cheap short circuit (which does not).

Evidence: with create_pool reverted to its pre-fix form both cases go red
(observed 10 backends after a single write, and refusal surfacing at pool
creation instead of at prepare time).
2026-08-24 10:38:36 -04:00
iomgaa eef2fdc5df fix: judge telemetry failures by nature, not by step
The pool exhaustion in issue #15 was fatal only because min_size=10 forced
a transient error to surface at pool creation, and that step was hardcoded
to permanent death. Step is the wrong axis: it conflates "the DSN cannot
be parsed" with "someone else holds all the connections right now".

Failures are now classified by two rules. Fatal means the cause lies
entirely inside this process and cannot change, which only the
construction-time DSN satisfies. Everything else splits on whether the
failure has anything to do with this row's data: row-level failures drop
one row and keep trying, environment-level failures cool down for 60s and
then get exactly one retry, so a restarted database or a DBA creating the
table heals on its own.

42703 (missing column) is the single named exception and stays row-level
even though every row fails alike: issue #13 promised that the manual mode
trims the INSERT and exposes drift per row, and that promise outranks the
rule. Any future exception owes the same argument.

The _failed boolean is gone; the tracker is the only degradation state,
because two copies of the same fact drift apart. Closing stays outside
that state: it is the caller's own decision, not an anomaly to recover
from, so the snapshot reports it through dropped_rows and the drop reason
instead of raising the degraded flag on every clean shutdown.
2026-08-24 10:18:53 -04:00
iomgaa bc071c6f41 fix: make closing the telemetry pool bounded and final
Closing was the last unbounded wait on the shutdown path: asyncpg's
Pool.close() awaits wait_until_released() on every holder, so a single
in-flight connection parks the caller forever (60s only buys a warning).
It now runs under asyncio.wait_for and terminates the pool on timeout;
external cancellation still propagates untouched.

Closing is also final now. Clearing _pool used to leave the recorder free
to build a fresh pool on the next write - worse in the injected case,
where the owner believes it still holds every connection while the
recorder quietly opened its own. Recovery is a runtime concern (cooldown
retry), not a side effect of shutdown, so writes after aclose short out
and count the dropped row with a reason of their own.

Also covers the release/terminate fallback left untested by the pool
work: the fake pool needed for the close cases makes it nearly free.
2026-08-24 09:53:23 -04:00
iomgaa 84c2cc11a4 feat: make the telemetry pool declare what it costs
The pool was the only external resource in the library that pre-allocated:
asyncpg's default min_size=10 turned pool creation into an all-or-nothing
action, so on a shared instance running low on connection budget the first
thing to fall over was the one component that must not fail silently
(4 clients x 10 = 40 idle connections just to write telemetry).

min_size=0 means "do not pre-connect" - asyncpg only builds holders - so
pool creation becomes free and never touches the database; connection
failures then land on acquire, the path that already drops one row and lets
the pool recover. max_size and the write budget become the library's
explicit statement about its own footprint, configurable through two new
keys whose defaults live in config alone (the recorder parameters are
required keyword-only, same discipline as auto_migrate).

The whole write - prepare, acquire, execute - now runs inside one
asyncio.timeout: acquire used to have no timeout at all, so a full pool
would hang forever on the caller's path. Release is explicit rather than
`async with`, because asyncpg shields release and reuses the acquire
timeout, which would let a single telemetry write consume twice the budget.
2026-08-24 09:32:35 -04:00
iomgaa f958138e83 feat: make telemetry degradation a first-class state
Telemetry degradation used to be a single warning and a private boolean.
In a long-running process that is indistinguishable from telemetry working:
issue #15 was only found by hand-reconciling milestone log lines against
llm_calls rows, after 19 calls had silently gone unrecorded. The SQLite
side was worse — once init failed, every write returned without even a
log line.

Degradation now has one shared owner. TelemetryStatusTracker holds the
state machine (enter/recover/drop/should-retry), announces entry and
recovery once each, and repeats the drop count under a row-and-time
double threshold so a degraded backend neither floods the log nor goes
quiet. Both recorders hold one; both count the rows they drop.

For programmatic consumers, TelemetryStatus is a frozen snapshot exposed
as telemetry_status on all three clients, resolved through a single
isinstance check. It is a separate optional port rather than a member of
TelemetryRecorder: that protocol is @runtime_checkable, so adding an
attribute would make every implementation that only defines
record_llm_call stop satisfying it — downstream isinstance assertions
would break on upgrade. The existing assertion in test_ports.py is what
keeps that decision honest.

Failure criteria are deliberately untouched here: Postgres still treats a
pool failure as permanent, only now visibly. `_failed` and the tracker
therefore both carry the verdict for the span of this one change; the
cooldown rework collapses them into the tracker alone.
2026-08-24 08:57:23 -04:00
iomgaa e69ca4c82c fix: make every client close what it built and nothing else
A client used to close whatever transport, recorder or cache it happened
to hold, injected or not, so the first client to shut down killed the
backend its siblings were still using. That is why the explicit-sharing
path the architecture prescribes was unusable in practice and downstream
projects fell back to one private instance per client. The mirror image
of the same gap: the redis clients the factories build for the limiter
and the breaker were never closed at all, because nobody kept a
reference to them once they were handed to the retry middleware.

Ownership is now stated once, the way RedisLimiter already stated it:
whoever builds a resource closes it, injected ones are left alone. The
constructor is the full-injection path, so it owns nothing by default
and only the factories mark what they built. RedisCache gains the same
rule for its own client, and the three copies of the "probe for aclose,
fall back to close" dance collapse into a single helper so the next
correction cannot land in only one of them.
2026-08-24 08:34:06 -04:00
iomgaa e7caa500e2 docs: plan the telemetry pool lifecycle rework for issue 15
The design traces the incident to four stacked defects rather than one bad
default: the pool is the only resource in the library that pre-allocates,
the kill switch keys off which step failed instead of what failed, the
degraded state can neither recover nor be observed, and the ownership rules
make the sanctioned sharing path unusable.

The plan sequences the tracker ahead of the pool and failure work so every
commit stays green, and records two facts the implementer needs up front:
the pool-construction path has zero test coverage today, and the commit
gate runs the full suite plus a complexity ceiling.
2026-08-24 08:17:37 -04:00
iomgaa 157a27f3bb chore: require python 3.12 and adopt PEP 695 type parameters
The telemetry write budget needs asyncio.timeout, whose uncancel accounting
was only fixed after 3.11.1 — pinning the floor at 3.12 removes that hazard
instead of working around it.

Raising ruff's target-version turns on UP047, so gather_bounded,
_anext_within and stream_with_liveness_timeouts move to def f[T](...) and
the two module-level TypeVars go away. That syntax is a SyntaxError on
3.11, so it can only land together with the version bump.
2026-08-24 08:14:45 -04:00
iomgaa 59a4bc3d14 Merge branch 'chore/e2e-slow-marks' 2026-08-24 00:24:51 -04:00
iomgaa 620b426ede test: keep gateway-dependent e2e out of the commit gate
The pre-commit hook runs the whole suite, and tests/e2e/ talks to a real
LLM gateway, so whether a commit is allowed depended on how fast that
gateway happened to be. During the issue 14 work it blocked two commits
on two different cases; both passed when rerun alone, and the suite went
from 165s to 336s that hour.

The wasted minutes are not the real cost. Retrying on red teaches you to
read "test failed" as "gateway was slow", and a genuinely flaky bug then
gets retried away too. An alarm that cries wolf stops being an alarm.

test_thinking_live.py already carried the slow marker; the other three
files now match it, and the release checklist gains an explicit
`pytest -m slow` step so they still run where a human is watching --
without that step this change would just delete the coverage.

Also raises test_flat_legacy_keys_assemble's LLM_TIMEOUT from 120 to
300, matching .env. At 120 the case allowed half of what production
allows, on a gateway that needs the full 300 -- it measured 116s in a
solo run. The assertion is that the flat key name parses into
SourceConfig.timeout_s; the value itself was never under test.
2026-08-20 05:49:28 -04:00
iomgaa f31f7caf99 Merge branch 'feat/issue-14-circuit-open-policy'
Close issue #14: an open circuit could only kill the call on the spot.

Three things. retry_after_s now means "how long until a retry is
certainly worth attempting", so a half-open gate and an admitted probe
both report 0.0 -- which also closes a bug the issue never spotted: that
value was fed into the source cooldown memo, whose set_until only moves
forward, so a source stayed skipped in-process for a whole probe lease
(up to 2x timeout) after its probe succeeded and the gate closed. Multi
source deployments were hit too; other sources just absorbed the load.

{SCOPE}__CIRCUIT_OPEN=fail_fast|wait fills the missing cell of the
admission matrix, shaped like QUOTA_FULL. Default fail_fast keeps every
existing control flow byte-identical; single-source scopes want wait.

And the admission logic that all three governance loops had copied
verbatim now lives once, in SourceAdmission -- otherwise this fix would
have left embedding and OCR behind as divergent corners.
2026-08-20 04:00:43 -04:00
iomgaa 41bca375d2 chore: cut 1.2.4 and date its changelog entry
README first, since packaging freezes whatever it says at build time:
version pin bumped, and the capability table now mentions that an open
circuit can wait as well as fail fast. Verified the numeric claims by
measurement rather than memory -- record_llm_call still takes 24 fields,
schema.COLUMNS still has 24, meta still caps at 16 keys.
2026-08-20 03:56:51 -04:00
iomgaa 84ee6dee84 docs: file the branch review outcome in the wiki
Records what the two Codex review rounds found, which findings held up
under verification, and how each was resolved -- including the one that
changed docs rather than code. Also lists the evidence behind the
completion claim: suite counts, coverage, the 19-minute real-wait Redis
run, and the import contract.
2026-08-20 01:04:43 -04:00
iomgaa c5b2b3fade docs: correct how a wait-mode call actually dies on a dead source
Branch review caught the docs claiming something the code does not do.
CHANGELOG, README and the design's behaviour matrix all said a
force-opened source under circuit_open=wait waits out the full stall
window. It does not: the probe let through after each cooldown is a
real attempt, so it burns a max_attempts slot like any other, and a
401 source usually runs out of retry budget first -- reason is
retry_exhausted, not stalled. Which budget wins depends on
max_attempts against the cooldowns and the stall window.

The behaviour is right; only the prose was wrong. Charging the probe
to the retry budget is exactly the split issue #8 settled: the
question is who spends max_attempts, and a probe does send a real
request. A test now pins it so the claim cannot drift again.

Also drops the planned "woke up" log line. Each wait round already
logs on entry with its duration, and a still-blocked wake-up logs the
next round immediately, so a second line would only double the volume.
2026-08-20 01:00:47 -04:00
iomgaa 5a025b6e5d style: run the formatter over the issue 14 changes
ruff format only; no semantic change.
2026-08-20 00:44:21 -04:00
iomgaa d9ceaecf20 docs: record the circuit-open wait policy and the retry_after contract
README gains the key with the reason a single-source scope wants wait,
and the price of choosing it. .env.example carries the same warning
since README points at it as the full key list. ARCHITECTURE 7.4 records
why the missing cell is unrelated to source count -- and why keying on
len(sources) would be the worse debt -- plus the six-exit retry_after_s
contract and the admission convergence; 9 registers the key.

CHANGELOG stays unreleased per the release checklist: the version bump
belongs to the release run, not here. Its "read this first" section
covers the half-open retry_after_s change, which is visible even on the
default fail_fast setting.
2026-08-20 00:36:36 -04:00
iomgaa 2a9bc44abf docs: take the retry duty back into the library
The GatewayUnavailableError docstring told callers to catch it and
retry later, which reads as an invitation for every downstream to write
its own retry layer. Two layers drift -- the library retunes its
backoff and the caller never hears, the caller changes its patience and
the telemetry cannot see it -- and after that nothing can answer how
long a call actually waited or how many attempts it made.

Call-level retry, backoff, source switching and cooldown waiting all
live in the library. The exception means that budget is spent. Retrying
past it is task-level retry, a different thing, and stays outside
(ARCH 7.2, single-layer retry). Also states what retry_after_s means
now and points at CIRCUIT_OPEN.
2026-08-20 00:30:30 -04:00
iomgaa 6edf4ac9de feat: let circuit_open=wait queue instead of killing the call
on_no_runnable now dispatches on why every source was rejected instead
of falling through two serial branches. Under wait, a fully open circuit
sleeps out the cooldown and comes back for another round; the breaker's
protection is untouched (still not a single request leaves during the
wait, so no quota or money burns) -- what changes is whether the caller
dies on the spot or queues.

Dispatching is not cosmetic. Left serial, wait would fall into the quota
branch and a caller with quota_full=fail_fast would get a
quota_exhausted error while its quota was in fact fine.

_nap sleeps to the cooldown deadline rather than polling every 10ms,
which for a 60s cooldown is 6000 round trips per in-flight call on the
Redis backend. Jitter is added on top instead of scaling the wait, since
waking early before a known deadline just earns another rejection. Both
arms clamp to the remaining stall budget, so the worst case per call is
stall_window plus one poll and does not drift with max_cooldown_s. The
clamp's lower bound is the jitter itself, not poll_interval -- the
latter would have lifted the existing [0.5p, 1.0p] quota polling.
2026-08-20 00:27:00 -04:00
iomgaa eb956b2cdf feat: add the {SCOPE}__CIRCUIT_OPEN admission policy key
Limiter rejections have always chosen between waiting and failing fast;
breaker rejections had no such choice. The new key is the missing cell
of that matrix, shaped exactly like QUOTA_FULL so there is nothing new
to learn. It defaults to fail_fast: flipping the default would move
every existing deployment's worst-case wall clock from milliseconds to
the stall window, which is the wrong direction to impose on anyone.
Single-source scopes are the ones that want wait, and they now have a
way to say so.

The two keys stay separate despite sharing a domain, because a full
quota is "queue for your share" (your turn always comes) while an open
circuit is "wait for the source to recover" (it might not).

Policy validation collapses into SourceAdmission, the only consumer.
The three client constructors used to each carry their own copy of the
quota_full check; adding a second key there would have made eight
copies of the same two lines. Rejection timing and message are
unchanged -- admission is built inside those constructors.

This commit only wires the key through; the control flow that reads it
lands next.
2026-08-20 00:17:46 -04:00
iomgaa 8edd3fb2cd fix: pin retry_after_s to the next certain retry moment
retry_after_s never had a written definition, so each backend improvised
and they drifted apart. It now answers exactly one question: how long
until a retry is *certainly* worth attempting. OPEN has such a moment
(the cooldown deadline); HALF_OPEN does not, because the probe can come
back at any time -- so it reports 0.0, which already means "retry now"
elsewhere in the library.

Six exits are brought in line. The half-open rejection is the one issue
14 reported: it returned the probe lease remainder, a deadlock-guard
value derived from 2x the slowest timeout, so a 60s cooldown told
callers to wait 600s. Worse, retry.py fed that number into the source
cooldown memo, whose set_until only moves forward -- a source stayed
skipped in-process for the whole lease even after its probe succeeded
and the gate closed. That now writes an already-expired deadline, so
the memo goes back to recording only real OPEN cooldowns.

The other five were pre-existing memory/redis divergences hidden by a
contract-test blind spot (the suite pinned that a second caller gets
rejected, never what number it got): redis reported the probe TTL on
grant and the lease remainder on fenced-out writes, where memory has
always reported 0. Contract cases now pin all four half-open exits on
both backends, with 1:1 real-wait variants for redis since the
fake-clock ones skip there.
2026-08-20 00:09:20 -04:00
iomgaa 942af99856 refactor: share one admission path across the three governance loops
_pick_runnable and _on_no_runnable lived in three copies (retry.py,
embedding.py, ocr.py), the latter two being verbatim subsets of the
first. Admission semantics keep evolving -- issue #8 changed the stall
accounting, M2.5 added the AIMD pacer, issue #14 is about to add a wait
policy -- and every round had to be applied three times.

SourceAdmission now owns picking a runnable source and deciding what
happens when none is available. The three loops keep their QuotaGate,
BreakerGate and pacer references because _attempt still needs them for
write-back and pacer.leave(); those instances are shared, not rebuilt
(a second pacer would split the in-flight counter). The cooldown memo
moves in wholesale since only admission consumes it.

Behaviour is unchanged: pick differs from the old chat copy only by the
pacer None-guards, on_no_runnable is verbatim identical, and the suite
reports the same 967 passed / 21 skipped / 32 deselected as before. The
one visible change is the settle-and-release warning text, which had
three variants ("permit", "embedding permit", "OCR permit") and is now
one. Tests importing _demote_call_failures follow it to its new home.
2026-08-19 23:57:01 -04:00
iomgaa 0b3e84b3be docs: design the circuit-open wait policy for issue 14
The breaker conflates "this source is unhealthy" with "kill this call
now". Limiter rejections already choose between wait and fail_fast;
breaker rejections had no such choice, so a single-source scope loses
its whole retry budget the moment the gate opens.

Design adds {SCOPE}__CIRCUIT_OPEN (default fail_fast, so existing
deployments keep their control flow) and pins retry_after_s to "time
until a *certain* retry moment" across all six gate exits. The latter
also fixes a separate bug the issue missed: a half-open rejection fed
the probe lease (up to 2x timeout) into the source cooldown memo, whose
set_until only moves forward -- so a recovered source stayed blacklisted
in-process long after the gate closed. That one bites multi-source
deployments too, it is just hidden when other sources absorb the load.

Human-approved 2026-08-19; both documents revised after Codex review.
2026-08-19 23:45:57 -04:00
iomgaa 296c765337 chore: cut 1.2.3 and date its changelog entry
Dates the unreleased section as 1.2.3 (2026-08-19) and moves both version
strings from 1.2.1 in lockstep. The human picked a patch number knowing
this release carries five breaking changes; that is deliberate.

Two lines added to the upgrade hints: the install pin move, matching what
1.2.1 recorded for its own, and a pointer saying the zero-row RLS
self-check now also lives in the README, since CHANGELOG.md never reaches
anyone who only reads the packaged README.
2026-08-19 22:38:43 -04:00
iomgaa 429d767737 docs: carry the 1.2.3 boundary into the packaged README
The README is the only prose the sdist freezes, so anything a downstream
needs after `pip install` has to be in it before the build.

Four gaps: the install pin still floored at 1.2.1, which lets an explicit
install land on a version without the schema mode or the text cap the
same README documents; the capability table never mentioned either new
key, telemetry_schema_sql, the retention script, or the DDL template; the
"your llm_calls may be silently empty" warning about the 1.2.1 RLS
template lived only in CHANGELOG.md, which is not in the sdist; and both
references to tools/telemetry_retention.py read as if pip shipped it.

The RLS note goes above the pg-template:rls anchor, not between it and
the fence, so the block parser in test_postgres_telemetry.py still finds
all seven blocks. Telemetry field count re-measured against
inspect.signature(TelemetryRecorder.record_llm_call) and schema.COLUMNS:
still 24, so the table's number stands.
2026-08-19 22:35:45 -04:00
iomgaa 91354e4e10 Merge branch 'feat/issue-12-telemetry-retention'
issue #12: downstreams now have a way to control what the telemetry table
keeps, for how long, and who can read it. PGW_TELEMETRY_TEXT_CAP caps
message bodies, responses and thinking at the single telemetry call site
-- default None, so nothing changes unless asked. Retention ships as
tools/telemetry_retention.py, dry-run by default and stepping aside for
DROP PARTITION on partitioned tables, so the library itself never holds
DELETE rights.

The README gains a production deployment template -- three roles,
REVOKE UPDATE/DELETE, RANGE partitioning, RLS -- whose SQL the
integration test parses out of the README itself and runs against a real
Postgres, so the document cannot drift from what works. Writing it
surfaced a defect in the 1.2.1 RLS template: it bound the write-side
policy to a GUC the recorder never sets, which rejected every INSERT and
left the table silently empty.
2026-08-19 15:27:13 -04:00
iomgaa 4b06093d6c refactor: split the retention arg checks per backend
_validate carried the whole matrix in one function (cc C/13, over the
branch quality gate). Splitting it by what is actually being checked —
shared, sqlite-only, postgres-only — puts every piece at A/B.

Ordering is the part that had to survive: the chain's order is the error
messages' priority, so a run with several bad flags still reports the
same one it did before. The --vacuum/--apply pairing therefore stays in
the shared step ahead of the backend branch, where it was; it is a "do
not rewrite the whole file when you only meant to look" rule, which
holds before the question of which backend a flag belongs to.

No behavior change: all nine parser.error strings are byte-identical and
in the same order, and the eight usage-error cases pass unmodified.
2026-08-19 15:19:35 -04:00
iomgaa ea9b6fbcd9 docs: record the retention boundary and its knobs
The unreleased entry now covers both issues as one release note: #13 hands
schema control to downstreams, #12 hands over the other half — deleting
data — and ships three knobs that change nothing by default.

Top of the section is the 1.2.1 RLS template defect Task 4 found. That
template bound the write-side policy to app.tenant_id, but PostgresRecorder
writes every tenant through one pool and never calls set_config, so every
INSERT is rejected — and telemetry degrades silently, so the symptom is an
empty table, not an error. The entry says how to check for it (count rows
with a BYPASSRLS role; grep the per-row write warning) and what the new
WITH CHECK (true) template trades away.

ARCHITECTURE gets #12's half of D15: the library must not even hold the
means to delete, because REVOKE UPDATE, DELETE and a retention policy can
only be reconciled by DROP PARTITION (owner) rather than DELETE (app).
7.8 and 9 record the text cap, its default of no truncation, and why the
cut is per text rather than over the serialized JSON.
2026-08-19 15:09:55 -04:00
iomgaa daf7ab3268 docs: ship a production deployment template with its own test
README 的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节: 三角色、
REVOKE + 触发器兜底、created_at RANGE 分区与 pg_partman retention、
库需要的最小权限、合规下游的推荐配置、截断覆盖面的诚实声明,以及
SQLite 侧按天轮转库文件的保留期建议。

模板 SQL 只有 README 里这一份: 集成测试用 HTML 注释锚点
(`<!-- pg-template:* -->`)把它解析出来,做受控标识符替换后在真实
PG 的临时 schema + 临时角色上逐条执行(doctest 同款范式)。测试里
另抄一份就会与 README 各自漂移,而"README 的 SQL 能跑"这个承诺只在
同源时才成立;解析不到必须当场红,故块名与占位符都显式钉死。

新增 5 条真实 PG 用例: app 能 INSERT 不能 UPDATE/DELETE(拿到的是
权限错而非触发器错)、report 只读、未设 app.tenant_id 时读为零行且
设了只见本租户、行落进当月分区、触发器拦得住 DELETE 却拦不住
DROP PARTITION(这是"清理只能走分区"的机械化依据)。

写侧 policy 定为 WITH CHECK (true) 而非等值比较: 库用一个连接池给
所有租户写遥测且从不发 set_config,把写侧绑到 GUC 上会让每条 INSERT
被拒,而遥测的失败方向是静默降级——表现是整表零行。
2026-08-19 14:42:36 -04:00
iomgaa 511aa4899c feat: add a retention script downstreams can schedule
The library only ever SELECTs/INSERTs into llm_calls (D15), so expiring
rows has to live outside it — holding DELETE would contradict the
REVOKE UPDATE, DELETE the deployment template recommends.

tools/telemetry_retention.py is dry-run by default and prints the row
count, the created_at window and the tenant_id spread so an operator can
tell whether the rows about to go are the intended ones. The Postgres
branch refuses partitioned targets with exit code 3 (DETACH/DROP
PARTITION is O(1); DELETE is not) and otherwise deletes in per-batch
transactions. Missing asyncpg exits 2 rather than degrading quietly:
this is an ops tool, and a silent "0 rows" reads as "already clean".

Exit codes are the contract with the scheduler, so argparse errors were
moved off 2 (now 1) to keep "bad flags" distinguishable from "cannot
reach the database".

The Postgres cases run against the real instance in throwaway schemas —
never public.llm_calls — and the batch case asserts the shared table's
row count is unchanged, so a search_path that failed to apply lands as a
red test instead of a deletion.
2026-08-19 14:11:22 -04:00
iomgaa c26b34e854 feat: wire the telemetry text cap through settings
`PGW_TELEMETRY_TEXT_CAP` now reaches the emitter on every assembly path.
Unset means no truncation, which stays the default: a truncated row is
no longer audit evidence and cannot be replayed, and downstreams rely on
that today. The flip side — contracts and bids sitting in `llm_calls`
indefinitely, multi-tenant — is spelled out in `.env.example` so readers
can weigh both.

All three `from_settings` paths are wired (chat, embedding, OCR): they
write the same table, so capping only chat would leave half of it
uncontrolled. `TelemetryEmitter.__init__` now rejects `text_cap <= 0`;
it is the single point where the three clients converge, so the direct
construction path — a public assembly route the settings guard never
sees — is covered too. `0` would otherwise reduce every body to a bare
elision marker.
2026-08-19 13:57:15 -04:00
iomgaa 33ed7ecdfc feat: cap telemetry bodies at a configurable length
Chat rows stored full message and response text with no upper bound, so
downstream contracts and tenders lived in llm_calls indefinitely. Add
_cap_text/_cap_messages in the single telemetry exit (_record), applied
after digest_messages and before json.dumps, plus to response/thinking.

Capping is per text, not over the serialized JSON: cutting the whole
string would emit invalid JSON into an unvalidated TEXT column. The cap
builds new dicts and never mutates in place — digest_messages passes
non-list content straight through as the same object, so an in-place cut
would silently poison the caller's messages and the cache key.

text_cap is required on TelemetryEmitter (internal class, three known
construction sites) and defaults to None on the three public clients, so
the default behaviour stays byte-for-byte identical. Settings wiring
lands separately.
2026-08-19 13:39:17 -04:00
iomgaa e0a33ecf93 Merge branch 'feat/issue-13-schema-mode'
issue #13: the library no longer alters a downstream Postgres table on
its own. PGW_TELEMETRY_SCHEMA_MODE is tri-state and defaults by backend
-- SQLite keeps auto-migrating a local file, Postgres switches to manual,
where a stale table gets a named warning with runnable SQL and the INSERT
is trimmed to the columns that exist rather than dropping every row.

Schema constants now live in telemetry/schema.py so the SQL the library
prints cannot drift from the DDL it runs, and telemetry_schema_sql is
exported for downstreams writing their own migrations. The PG write drops
its conflict target, which partitioned tables require and which issue #12
depends on.
2026-08-19 13:24:05 -04:00
iomgaa 0721cf60aa fix: reject an empty column set in insert_sql
`insert_sql(backend, [])` 此前返回 `INSERT OR IGNORE INTO llm_calls () VALUES ()`
与 `INSERT INTO llm_calls () VALUES () ON CONFLICT DO NOTHING`,两条都语法非法。
入参正来自数据库列探测(遇到一张与本库毫无共同列的同名表,裁剪结果就是空),
把"非空"押在调用方的不变量上不成立——共享构造器自己拒,与它既有的"未知
backend""非 COLUMNS 子集"两道校验同款。

连带风险已实测确认: 两个 recorder 的空集回落都发生在调用 `insert_sql` **之前**,
故新增的 raise 不会逃出 SQLite 的 `__init__`(遥测初始化失败必须静默降级)或
PG 的准备期(`_prepare_schema` 里那次调用在 try 之外,异常会一路冒给业务调用方)。
新增 SQLite 空探测结果用例: 构造成功、写入照常、只有 warning。

同时补 PG 侧"探测结果与 COLUMNS 无交集"的回落用例(此前只有 SQLite 侧有),
并把承认缺口的那段测试注释改成断言拒绝。
2026-08-19 13:18:21 -04:00
iomgaa ba4a138692 docs: document the schema mode and the expand-contract promise
README 新增「遥测表 schema 与升级纪律」: 库对下游库只发探测/INSERT/建表
三类语句、PGW_TELEMETRY_SCHEMA_MODE 三态与两端不对称缺省的理由、
telemetry_schema_sql 用法,以及五条 Expand/Contract 承诺。CHANGELOG 未发布段
把三处破坏性变更放在最前。ARCHITECTURE 新增 D15 并在 §7.8/§9 记下 schema
单一事实源与无冲突目标写入。

新增集成用例把 telemetry_schema_sql("postgres") 的输出在空临时 schema 里执行
两遍: 断言物理列 == COLUMNS ∪ {created_at},且第二遍不报错(补列语句的
IF NOT EXISTS 幂等性)。去掉 IF NOT EXISTS 该用例即红。
2026-08-19 13:03:45 -04:00
iomgaa 483683b834 test: prove manual mode leaves a stale table untouched
真实 Postgres 上验收 issue #13 的 manual 档: 22 字段旧表加 auto_migrate=False,
information_schema 断言列一个不加(23 列而非 auto 档的 25),裁剪后的 INSERT 照常
落库,其余 22 列逐列与提交值相等;least_privilege_pre_tenant_dsn(缺列旧表 + 只授
SELECT/INSERT 的角色)下补列失败与写入失败两类 warning 全部消失,只剩一条点名
tenant_id/meta 并附可直接执行 ALTER 的准备期提示。

沿用既有隔离纪律: 临时 schema + search_path,teardown 只删自建对象,不碰共享的
public.llm_calls。

红证据(两种取法都做了):
① 把两例的 auto_migrate 临时改成 True —— 列断言红("Left contains 2 more items,
   first extra item: 'tenant_id'"),补列断言红("Postgres 遥测补列失败(写入将逐行
   降级): must be owner of table llm_calls")。
② 把 postgres.py 的 _trim_columns 临时退回 Task 3 之前(manual 档不裁剪不提示)
   —— 两例均红于 "Postgres 遥测写入失败(丢弃该行): column \"tenant_id\" of
   relation \"llm_calls\" does not exist"。
两次红都已还原,18/18 通过。

_record_minimal 改为返回实际提交的字段: 逐列断言另抄一份期望值时,抄错的列会伪装
成"库写错列位",漏抄的列则根本不被验证。
2026-08-19 12:41:26 -04:00
iomgaa 7b49e580c0 feat: expose the telemetry schema SQL to downstreams 2026-08-19 12:31:19 -04:00
iomgaa e17e1067a1 feat: derive the schema mode from the telemetry backend 2026-08-19 12:25:52 -04:00
iomgaa 21a19ab374 refactor: converge the missing-column warning into the schema module
两个 recorder 里逐字重复的 `_missing_columns_message` 收敛为 `schema.py` 的
`missing_columns_warning(backend, missing, *, alien_table)`。这条消息拼的是
给人执行的 DDL,与库自己执行的 ALTER 必须同源——留在两个 recorder 里等于在
单一事实源上开了个口子,而 Task 7 的文档还要引用这个消息格式。

纯收敛,行为零变化: 两端语句仍分别取自各自的 `SQLITE_BACKFILL` / `PG_BACKFILL`
(函数内不硬编码任何 DDL 文本),措辞、标点与换行逐字保留。已用改动前后的两份
实现对 16 组入参(4 种缺列组合 × alien 两态 × 两后端)逐串比对,输出完全相同。

顺带把 postgres.py 从 281 行降到 250、sqlite.py 降到 157。
2026-08-19 12:12:15 -04:00
iomgaa e949edb62a feat: gate the automatic ALTER behind an explicit mode
两个 recorder 的 `__init__` 增 keyword-only 必填 `auto_migrate`(设计 D-c:
缺省规则只写在 config 一处,不与类签名漂移),并把写入语句从模块级常量改为
实例级: manual 档探测到旧表缺列时一条 ALTER 都不发,改按现有列裁剪 INSERT,
准备期发一次 warning(逐列点名 + "以下维度不会被记录" + 可直接执行的补列 SQL)。

裁剪是关掉 ALTER 的前提而非增强: 旧表缺列时若既不 ALTER 又不裁剪,每一行
INSERT 都撞 `no column named tenant_id` 被整行丢弃,比自动 ALTER 更严重地
违反"遥测必录"。auto 档行为逐字不变(先探测后 ALTER、duplicate column 视为
成功、失败只 warning 不判死、写入沿用全量列)。

探测失败、或探测结果与 COLUMNS 毫无交集,两档都保守回落全量列——空列集会让
`insert_sql` 产出 `INSERT INTO llm_calls () VALUES ()`(它不拒空列表,空集
技术上是子集)。PG 侧 `_columns`/`_insert` 与 `_schema_ready` 在同一处一起
赋值,不留"已就绪但语句还是旧的"窗口。

同批改 `GatewaySettings.telemetry_auto_migrate`(按后端派生: PG False、
SQLite True)与 `client._build_telemetry` 透传: 签名变更与其唯一调用点必须
落在同一次提交,否则该提交点整条装配路 TypeError。env 键留给下一步。
2026-08-19 11:57:41 -04:00
iomgaa ecc22b34fc fix: drop the conflict target so partitioned tables can accept writes
PG requires a partitioned table's unique constraints to include the
partition key, so issue #12's RANGE partitioning on created_at forces
the primary key to (call_id, created_at). The old
`ON CONFLICT (call_id) DO NOTHING` then matches no constraint and PG
rejects every row with

    there is no unique or exclusion constraint matching the
    ON CONFLICT specification

which the recorder swallows as a per-row warning: telemetry would go
silently dark under a partitioned deployment. The target-free form is
valid on both table shapes and is literally equivalent on a plain table
(the primary key is its only unique constraint). SQLite's
`INSERT OR IGNORE` already carries no target and is untouched.

Integration coverage on the real PG instance, both inside self-created
temp schemas: a plain table still keeps one row per call_id, and a
table partitioned by created_at now accepts writes and reads them back.
The second case was red before this change with the error above.
2026-08-19 11:34:13 -04:00
iomgaa d4b40b0e64 style: reformat three files the current ruff would rewrite
Not related to the schema work. These three fail ruff format --check on
main as well -- the pinned ruff is newer than whatever last formatted
them -- and a red make check makes the per-task quality gate useless for
everything that follows.
2026-08-19 11:22:07 -04:00
iomgaa 1471e0a2c6 refactor: make the telemetry schema a single source of truth
DDL, column order and backfill statements lived twice, once in each
recorder. A public telemetry_schema_sql() would have made three copies,
and the drift shows up downstream as "I ran the printed SQL and the
library still reports a missing column".

Move both DDLs, both backfill lists and the 24 INSERT fields into
telemetry/schema.py verbatim; the recorders now import them and build
_INSERT through insert_sql(backend, COLUMNS) at import time. The
generated statements are byte-identical to the previous constants, so
runtime behaviour is unchanged (the postgres conflict target stays
bound to call_id for now).

insert_sql() validates its columns against COLUMNS: from the next task
on those names come from database probing, not from a constant, so the
subset check is the gate on the only injection surface. The new
telemetry_schema_sql() prints a paste-ready migration script; its
postgres backfill deliberately uses ADD COLUMN IF NOT EXISTS while the
library's own statements do not, because that form takes an ACCESS
EXCLUSIVE lock even when the column exists. Both variants are derived
from one declaration list so their column sets cannot drift.
2026-08-19 11:18:06 -04:00
iomgaa e9adb36577 Merge branch 'design/issue-12-13'
Designs and plans for issues #13 and #12, both reviewed by Codex and
approved by the human gate.
2026-08-19 11:02:44 -04:00
iomgaa 172f3180e5 docs: record what the plan review changed 2026-08-19 09:27:54 -04:00
iomgaa 8f792bc697 docs: correct the plans against what the code actually does
The plan review caught three mistakes that would have gone red in the
tests rather than in the implementation. Column counts: COLUMNS is the
insert field list and excludes the database-filled created_at, so a
stale table has 23 physical columns and a current one 25, not 22 and 24.
Warning capture: the library logs through loguru, which never reaches
caplog, so that assertion would have passed forever without seeing a
single line. And the stale-table-under-least-privilege fixture is
least_privilege_pre_tenant_dsn -- the other one builds a complete table
and never reaches the missing-column path at all.

Three more: make lint rewrites files, so verification uses make check;
the recorder signature change now ships with its only call site instead
of leaving a TypeError between two commits; and the backfill statements
the library runs are not the ones it prints -- the library probes first
to dodge the exclusive lock, while a script handed to a DBA has to carry
IF NOT EXISTS or it cannot be run twice.

On the cap side, all three clients build their emitter inside __init__,
so a required parameter there would strand anyone constructing a client
directly. The emitter stays required, the clients take a defaulted one.
2026-08-19 09:25:33 -04:00
iomgaa 5b2e3ba82d docs: plan both telemetry changes down to the task level
Twelve tasks across the two plans, each with the files it touches, the
evidence it has to produce, and the command that proves it. #13 goes
first: both branches edit config.py and client.py, and #12's
partitioning template leans on the schema SQL helper and the untargeted
conflict clause that #13 introduces.

Writing the cap plan surfaced a trap worth its own guard. digest_messages
appends the very same dict when a message's content is not a list, so
the telemetry copy, the caller's messages and the cache key all share
one object -- capping in place would poison the caller's request and the
cache key at once, silently. Two red-line tests now pin that down, and
the plan asks for an in-place version to be written and run first, to
prove the tests actually catch it.
2026-08-19 09:13:20 -04:00
iomgaa 39fcf2631d docs: fix the partitioning conflict the review caught
Postgres requires a partitioned table's unique constraints to cover the
partition key, so ranging on created_at forces the primary key to
(call_id, created_at) -- and ON CONFLICT (call_id) DO NOTHING then
matches no constraint at all. The retention design claimed INSERT stays
transparent under partitioning; that holds for the routing, not for the
conflict target, and telemetry would have failed outright on any
partitioned deployment. The write drops its conflict target, which is
byte-equivalent on a plain table and legal on both.

The cap design gains the three emitter construction sites it has to
touch and the relationship to the 200-char caps embed and OCR already
carry: they stay, and the new cap is the stricter of the two. Covering
all three call paths is deliberate -- their rows land in one table, and
issue #11 settled that argument already.
2026-08-19 08:59:30 -04:00
iomgaa 72b6b54719 docs: design the telemetry schema gate and the retention boundary
Both open issues ask the same question from opposite sides: how much
power the library holds over a downstream database. #13 wants the
structural writes back, #12 wants the data retention back. The two
designs share one boundary -- the library does SELECT and INSERT plus
an optional CREATE, and everything that alters structure or deletes
rows belongs to the downstream, with the library obliged to print the
exact SQL they need to run.

Two findings shape #13 beyond what the issue argues. The precedents it
cites (Hangfire's lock queue, Prefect's multi-instance race, Alembic's
audit trail) all live on a shared production Postgres, while the SQLite
side is a local file with no DBA and no migration tool, so the defaults
split by backend rather than uniformly. And turning ALTER off only
works together with trimming the INSERT to the columns that exist:
without it a stale table drops every row instead of two columns, which
breaks the telemetry rule harder than the automatic ALTER ever did.

For #12 only the body cap touches library code; retention and access
control land in the README, because the sdist carries src and the
README alone -- a template that lives in the wiki is one a downstream
pip install cannot reach.
2026-08-19 08:48:27 -04:00
iomgaa 2af445cfc2 chore: cut 1.2.1 with the docs the sdist will freeze
Dating the changelog and bumping both version strings is the cheap half.
The README is the half that gets frozen into the sdist, so it is fixed
first: the install pin now names 1.2.1 (1.2.0 has no tenant dimension),
and the per-source FIELD table finally lists MISSING_DONE and EXTRA_BODY
- the latter was already referenced elsewhere in the same file. The same
table was missing QUOTA_FULL, the embedding-only keys, the memory cache
backend and three optional PGW_* keys; all are reconciled against
_SOURCE_FIELDS and _load_pgw rather than from memory.
2026-08-18 12:54:54 -04:00
iomgaa 8b0f66b1a6 Merge branch 'feat/issue-11-caller-dimensions'
Issue #11: a multi-tenant caller could not isolate its rows in the
telemetry table, because llm_calls carried no tenant dimension at all --
only session_id and parent_call_id, both free-form strings the library
never validates. The table stores full message bodies, so several
tenants' contracts sat in one table with no way to filter by owner.

tenant_id is a real column rather than a key inside JSON, for two
independent reasons found during research. An RLS policy on
meta->>'tenant_id' parses fine, but the planner discards statistics for
non-LEAKPROOF functions under RLS and ->> is not marked leakproof.
Separately, the planner has no usable JSONB statistics at all. Both
degrade unpredictably at real data volumes, and neither reports an
error.

Anything else the caller wants to attach goes into meta, a JSON column
with no index -- the same split LiteLLM, Loki, and six LLM observability
platforms arrived at independently.

The library stops at the column plus a documented policy template. It
never enables RLS itself: with no matching policy that is default-deny,
which would have silently failed every telemetry write for the two
downstreams that are not multi-tenant.

Old rows read back as the empty string rather than NULL. Under an RLS
policy NULL is invisible to everyone, which is not what "unassigned"
should mean.

Covers all three telemetry paths -- chat, embed, and OCR. The last was
not in the issue, but OCR rows land in the same table and the same
irreversibility argument applies to them.
2026-08-18 04:57:56 -04:00
iomgaa 9d9e4ee533 docs: point the deferred items at the issues that now hold them
The design said three times that retention and the _BACKFILL question
would be filed separately, and neither had been. That is the failure
mode the release checklist already records: a closing step nobody does
and nobody notices. Filed as #12 and #13, and the design now names them
so a later reader can follow the thread instead of trusting a promise.
2026-08-17 23:02:12 -04:00
iomgaa 56f380534c docs: ship the RLS template where downstream can actually read it
The CHANGELOG pointed at research-wiki for the RLS template and its
three traps, but setuptools has no MANIFEST.in here: the sdist carries
src/polygateway and the README only. A downstream pip install could not
reach any of it. The template and the traps now live in the README
section on multi-tenancy, and the CHANGELOG points there.

ARCHITECTURE.md is the single source of truth for architecture, and this
change had added nothing to it. Section 5.2 gains an entry in the same
shape as the issue #4 overlay one, and 7.8's field list gains tenant_id
and meta -- plus reasoning_tokens, which issue #6 had already left out,
so the port's field-count chain reads 18 to 20 to 21 to 22 to 24 with no
gaps.
2026-08-17 12:31:07 -04:00
iomgaa b6165ff438 test: close two always-green holes in the dimension tests
The cache-key test only asserted a hit, so a key degraded to a constant
would still pass it. Adding a namespace control group that must miss
proves the key still distinguishes inputs; verified by degrading
build_cache_key to a constant and watching the case go red.

The allow_nan=False branch had no test at all. A ChatRequest built with
a nan meta value (bypassing the entry validation, i.e. a future entry
point that forgets to validate) must drop the row and not raise;
verified red by removing allow_nan=False.

Also restore the read-only file permissions in a finally block, so a
failing assertion does not get masked by a PermissionError from tmp_path
cleanup; rename the warnings fixture to captured_warnings so it stops
shadowing the stdlib module; and drop a downstream business term from a
fixture value (zero-business-assumption rule).
2026-08-17 12:28:47 -04:00
iomgaa 9bdd312928 fix: check the meta key budget before scanning every key
The key-count cap exists to catch a whole request body dumped into meta.
That is exactly the shape where the per-key regex runs tens of thousands
of times before the real reason surfaces, so the cheap check goes first.

Also correct two stale docstrings: postgres.py still claimed 22 columns
(it is 24), and _canonical_meta_json promised to raise on non-finite
floats. It is evaluated inside _record's degradation try, so the real
outcome is a warning plus a dropped row -- never an error the caller
sees. What the gate actually buys us is the SQLite side, whose meta is a
TEXT column that would happily store a literal NaN.
2026-08-17 12:26:37 -04:00
iomgaa 25cb0a6e0c docs: document caller dimensions and the RLS boundary
Issue #11 Task 8, repo files only (the Gitea wiki pages are handled
separately at merge time). CHANGELOG gains an unreleased section covering
the two new keyword-only parameters on all four public methods, the two
new telemetry columns (JSONB on PG, TEXT on SQLite), why backfilled rows
read as an empty string rather than NULL, the validation limits, and the
boundary that the library ships columns only - no index, no RLS.

Telemetry field count re-measured via inspect.signature: 22 -> 24, README
updated accordingly. The cache-key row also dropped the sampling
component and called the namespace a tenant, which now reads as the new
tenant_id; both corrected.
2026-08-17 11:59:30 -04:00
iomgaa d553d142c3 test: prove old telemetry tables gain the tenant column safely 2026-08-17 11:49:07 -04:00
iomgaa 6ad58a6553 feat: carry caller dimensions through the OCR chain
OcrClient is the third telemetry path that skips the chat onion: _emit
builds its own ChatRequest purely to reuse the shared TelemetryEmitter,
so wiring chat() and embed() alone left every OCR row without a tenant
while those rows land in the same llm_calls table. Take the dimensions
at both public entries, validate them there (anything failing further
down is degraded to a warning), and thread them through _call ->
_attempt -> _emit so success, rejection, cancellation and retryable
failure rows all carry the same pair.
2026-08-17 11:34:39 -04:00
iomgaa 702040d1a3 feat: carry caller dimensions down the embedding chain
EmbeddingClient does not go through the chat onion: it builds its own
ChatRequest inside _emit purely to reuse the shared TelemetryEmitter, so
wiring chat() alone left every embed row without a tenant. Validate the
dimensions at the embed() entry (before batching, since anything failing
further down is degraded to a warning) and thread them through
_embed_batch -> _attempt -> _emit so every batch row carries the same
pair.
2026-08-17 09:53:37 -04:00
iomgaa 4be2b4f287 feat: let chat() take a tenant and caller-defined dimensions
Validation runs before the request enters the onion: every failure inside it
is downgraded to a warning by the telemetry layer, so validating in there
would not validate anything.

The dimensions stay out of the cache key — cache_namespace already carries
tenant isolation, and folding meta in would cold-start every existing entry.
2026-08-17 09:43:51 -04:00
iomgaa dba706b59c feat: record each call's tenant and caller-defined dimensions
Both telemetry backends gain tenant_id and meta at the end of the
column list, and TelemetryEmitter fills them from the request. The two
halves ship together because the emitter is the only caller of
record_llm_call: adding the columns without filling them leaves every
row short of two keys, and the backends read those keys outside their
try block, so the KeyError degrades to a warning and the whole table
stops filling.

The columns are appended, never inserted. An old table can only gain
columns through ALTER, which puts them last; a new table built from the
DDL would put them wherever the DDL says. Anywhere but the end and the
two paths produce different physical column orders, while the INSERT
uses positional placeholders.

The two backends spell the default differently for different reasons.
SQLite refuses a NOT NULL column without a non-NULL constant default
outright, so the default is what makes the backfill legal at all. On
Postgres a non-volatile constant default is what keeps the ALTER from
rewriting the table, and NOT NULL DEFAULT '' is what keeps old rows out
of the black hole a NULL tenant_id falls into under an RLS policy.

Normalisation happens in the emitter, not the recorder, matching how
canonical_sampling_json already settles the sampling column: None
becomes the empty string, an empty mapping becomes the literal '{}'.
Keys are sorted so one set of dimensions serialises identically on
every row, and allow_nan=False is a second gate behind the entry
validation -- json.dumps would otherwise write a bare NaN, which JSONB
rejects, and the failed insert would be swallowed as a warning.

All three emit entry points read the request. Cache hits read it too,
rather than the replayed response: the dimensions answer who made this
call, not who made the one whose result is being replayed.
2026-08-17 09:36:38 -04:00
iomgaa 6af4673534 feat: validate the dimensions a caller may attach to a call
Adds validate_caller_dimensions and the two ChatRequest fields that
carry them. Every limit rejects rather than trims: Langfuse drops
metadata values past 200 characters, which leaves the caller believing
something was recorded when nothing was.

Whitespace on tenant_id is refused outright instead of stripped. " t1"
and "t1" compare unequal inside an RLS policy, so silently rewriting the
caller's value would hand them a tenant whose rows they cannot find.

Non-finite floats are refused for a concrete reason: json.dumps writes
them as the bare literals NaN and Infinity, which are not valid JSON and
which JSONB rejects. Letting one through turns a caller's input mistake
into a failed insert, and the telemetry layer degrades failed inserts to
a warning -- so the mistake would surface as missing rows, nowhere else.

The new fields go after sampling so no positional construction of
ChatRequest shifts. Validation is split across three helpers to keep
each one under the complexity gate.
2026-08-17 06:30:30 -04:00
iomgaa bf2fbd6c5e docs: fold the OCR path into the approved scope for issue #11
OcrClient emits through the same helper and its rows land in the same
table as chat rows. Covering only chat and embed would leave one table
holding rows that have a tenant and rows that never will, and the
issue's own irreversibility argument applies to those rows too.

The design said two paths because the issue said two paths. Corrected
at the source rather than only in the plan, so a later reader does not
find OCR work with no design behind it.
2026-08-17 06:22:47 -04:00
iomgaa 80aa2b216d docs: tighten the issue #11 plan after Codex review
The tenant_id rule was wrong in a way that would have shipped: the plan
said reject when strip() is empty, but the design says reject leading and
trailing whitespace outright. " t1" survives the weaker rule and then
compares unequal to "t1" inside an RLS policy, so a caller who pads the
value silently loses rows.

Adds the test that guards a promise nothing else was guarding -- same
messages and namespace with different meta must still hit the cache.
Without it, folding meta into the key passes every other assertion and
costs a full cache cold start plus a permanently lower hit rate, which
degrades quietly instead of failing.

Also pins _record's new parameter positions, splits the backfill-failure
setup per backend (ownership check on PG, read-only file on SQLite, and
says what SQLite cannot assert), puts the red-green gate on the
integration task, and names the two wiki pages.
2026-08-17 06:18:50 -04:00
iomgaa a052f3eb28 docs: plan the implementation for issue #11
Eight tasks against the approved design, ordered so the port and both
telemetry backends land before the three call paths that feed them.

Writing the plan turned up a third telemetry path the design missed:
OcrClient emits through the same helper and builds its ChatRequest on
the spot, just as embedding does. OCR rows share the table with chat
rows, so leaving them out would put a hole in a multi-tenant caller's
audit trail, and the same irreversibility argument applies. Listed as
Task 6 and flagged as beyond the approved scope -- it may be dropped,
but only by stating the limitation in the CHANGELOG, not silently.

The integration task pins the issue's own argument as a test: build a
22-column table, open it with the current recorder, and assert the old
rows read back as the empty string rather than NULL -- NULL under an
RLS policy is invisible to everyone, not merely unassigned.
2026-08-17 06:10:53 -04:00
iomgaa b671fb629a docs: close the four gaps Codex found in the issue #11 design
The embedding client does not go through the chat onion -- embed() runs
its own chain down to _emit(), which builds a ChatRequest on the spot
and so far only fills session_id and parent_call_id. Changing chat()
alone would have left every embed row with empty dimensions, which is
exactly what the issue's second request asks for.

The bigger find: the draft claimed serialization could not fail because
the entry check already restricts values to scalars. It can. A float
passes a naive type check and json.dumps writes it as the literal NaN,
which is not valid JSON and which JSONB rejects; the failure then lands
in the emitter's degrade path and turns a caller's input error into
silently dropped telemetry. Now rejected at the entry with isfinite and
again at serialization with allow_nan=False.

Also states the validation runs at both public entries, not just chat(),
and adds the RLS template the design had promised but never wrote down.
2026-08-17 06:03:42 -04:00
iomgaa 61122ce437 docs: design caller-defined dimensions for the telemetry table
Issue #11 asks for a tenant column so a multi-tenant caller can isolate
rows in the database. Widened to caller-defined dimensions in general,
but only the caller's own: model name and friends keep their existing
columns, and the library writes nothing into the new container.

Two independent findings force tenant_id to be a real column rather than
a key inside JSON. An RLS policy on meta->>'tenant_id' parses fine, but
the planner discards statistics for non-LEAKPROOF functions under RLS,
and ->> is not marked leakproof; the pgsql-general report that hit this
ended up moving the indexed column out of JSONB. Separately, the planner
has no usable statistics for JSONB at all -- @> falls back to a
hardcoded 0.1% selectivity.

A configurable promoted-column whitelist is rejected: when two
downstreams infer different types for the same key, the second
ADD COLUMN is silently skipped by IF NOT EXISTS and the wrong type is
written from then on, without an error.

The library stops at the column plus a documented policy template. It
must never enable RLS itself -- with no matching policy that is
default-deny, which would silently fail every write for the two
downstreams that are not multi-tenant.
2026-08-17 05:55:40 -04:00
iomgaa 4351e2be73 docs: the package link API works now, drop the manual workaround
Measured 201 on POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/
PolyGateway during the 1.2.0 release. The note saying it 404s and must
be done through the web UI would have sent the next release down a
manual path that is no longer needed.
2026-08-16 23:41:44 -04:00
iomgaa 17dcff41c3 Merge branch 'feat/issue-10-error-body-retention' 2026-08-16 23:34:20 -04:00
iomgaa fa4a7e220b test: make the 429 red line actually catch its violation
Verifier mutation test: flipping _translate_429 to parse the summary
left all 824 tests green. The padding was one long string value, so the
cut landed inside it - and head-and-tail retention kept the trailing
error object, leaving the summary parseable. Many keys put the cut
between structural tokens, where the summary stops being valid JSON.
Mutation now fails as it should. Also splits OCR 429 out on its own.
2026-08-16 06:50:50 -04:00
iomgaa 658086e2c0 docs: release 1.2.0 and unpin downstream from the 1.1 series
Issue #10 Task 6. The install pin moves from ==1.1.* to >=1.2,<2 - left
alone, everyone following the README would have stayed silently on
1.1.2 without this fix and without a warning. Telemetry field count
re-measured via inspect.signature: still 22.
2026-08-16 06:22:13 -04:00
iomgaa 9dada0be9d test: prove a rejected call's reason reaches the telemetry table
Issue #10 Task 5, the acceptance claim. Before the fix this asserted
against 'qwen_1 请求被拒: 400' and failed on the first substring - which
is exactly what the downstream batch was left with. Uses the real body
from the issue, and checks the trailing code too, since a head-only cut
would drop the one field you quote when chasing the provider.
2026-08-16 06:17:10 -04:00
iomgaa a3f4cc323f feat: keep the gateway's words on the OCR branches too
Issue #10 Task 4: the OCR side said only 'HTTP 404'. The issue reported
the chat path, but the batch that lost its 400 was reading tables - the
same blind spot, one transport over. Reuses the shared summarizer.
2026-08-16 06:14:07 -04:00
iomgaa 0edb9d397a feat: keep the gateway's words on every non-2xx chat branch
Issue #10 Task 3: five branches each built their own message, so adding
the summary would have meant five copies. Table-driven classification
composes it in one place instead, and the 429 split still parses the
untruncated body - reading the summary would demote an oversized
insufficient_quota to a plain rate limit and stop force_open.
2026-08-16 06:07:29 -04:00
iomgaa 484900d300 feat: add the single summarizer for HTTP error bodies
Issue #10 Task 2: head-and-tail rather than a head-only cut, because the
code and request_id that let you chase the provider sit at the very end
of a JSON error body. Cap 2048 follows k8s client-go for the same job.
2026-08-16 06:03:26 -04:00
iomgaa e302247022 feat: let every gateway error carry what the gateway said
Issue #10 Task 1: a rejected call's reason had nowhere to live. The
field goes on the base class because these errors all come from one HTTP
response - which class it is and what the peer said are orthogonal.
2026-08-16 06:01:38 -04:00
iomgaa 1489aab95d docs: add the missing imports to the plan's key interfaces
Codex review: the code blocks reference httpx and PolyGatewayError, but
neither module imports them today. A zero-context implementer copying
them verbatim would stall on F821.
2026-08-16 05:57:07 -04:00
iomgaa c2dd4a1cf4 docs: plan the implementation for issue #10 2026-08-16 05:50:37 -04:00
iomgaa 1801289277 docs: mark the issue #10 design approved 2026-08-16 05:34:46 -04:00
iomgaa 7462cad166 docs: widen the body cap to 2048 and keep the tail
The 500-char head-only rule came from a single sample. k8s client-go
caps the same thing at 2048; reprlib keeps head and tail because the
text is meant to be read. Gateway error bodies are JSON whose code and
request_id sit at the very end, so a head-only cut drops exactly what
you need to chase the provider. Version pinned at 1.2.0, which forces
the README install pin off ==1.1.*.
2026-08-16 05:24:30 -04:00
iomgaa 3cbe8aab91 docs: register the issue #10 design in the research wiki 2026-08-16 05:12:14 -04:00
iomgaa 707f8f7317 docs: pin the truncation rule to arithmetic after Codex review
"Truncate at cap and append the ellipsis" admits both 501 and 500 total
length; the two would desync test assertions from the telemetry length
promise. Cap is now the total including the marker.
2026-08-16 05:09:06 -04:00
iomgaa 10fbc5441e docs: design how the gateway's refusal survives the transport layer
Issue #10: the 400 body dies in _status_to_error, and telemetry only
writes str(exc), so adding a field alone would not make the refusal
queryable after the fact. Design keeps the summary in both the message
and a new base-class body_text, across every non-2xx branch and both
transports.
2026-08-16 05:03:33 -04:00
iomgaa 114fc8b1b3 Merge branch 'chore/packaging-metadata' 2026-08-07 21:48:15 -04:00
iomgaa 4f1ab21562 chore: give the package page a body and repo links
1.1.2 went out with an empty description on the registry page: without a
readme field there is no long_description, and twine only warns about
that -- it does not block the upload. Add readme and project.urls, and
record the wider lesson in the release procedure: a release is done when
the pages a downstream user actually opens look right, not when the local
steps go green. Also adds the missing step for creating a Release, which
is why the Releases page sat empty through eight tags.
2026-08-07 21:48:15 -04:00
107 changed files with 20057 additions and 1375 deletions
+57 -3
View File
@@ -2,7 +2,8 @@
# 键名清单 = M1 设计文档 §8 定稿;缺关键配置直接报错,不做默认值兜底。
# ══ 多源配置: {SCOPE}__{PROVIDER}__{N}__{FIELD} ══
# PROVIDER 必须是注册表键(qwen/deepseek/openai,或 register_provider 注册后经 registry 传入)。
# PROVIDER 必须是注册表键(八段: qwen/deepseek/zhipu/moonshot/minimax/openai/anthropic/google,
# 或 register_provider 注册后经 registry 传入)。
# 必填: BASE_URL / API_KEY / MODEL / TIMEOUT_S(或用平铺 LLM_TIMEOUT 作缺省)。
LLM__QWEN__1__BASE_URL=
LLM__QWEN__1__API_KEY=
@@ -15,7 +16,19 @@ LLM__QWEN__1__TIMEOUT_S=120
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不表态 / true=要求开启 / false=要求关闭
# 本键是 REASONING_EFFORT 的语法糖: true ≡ auto、false ≡ none、缺省 ≡ 不表态
# "要求开启"注入什么随 provider 段而定: openai/anthropic/google 三段的开启形态是
# on_base={}——一个字节都不注入,走模型自己的默认档(该默认档若不推理,本键不会报错
# 也不会开推理,见 CHANGELOG 1.3.3「已知限制」/ issue #21);要确保开启请配 REASONING_EFFORT
# LLM__QWEN__1__REASONING_EFFORT=low # 本源默认推理档位;缺省=不表态(随模型自己的默认档)
# 八档(封闭词汇): none | auto | minimal | low | medium | high | xhigh | max
# none = 要求不推理(与"缺省不表态"是两回事);auto = 要求推理但不指定强度
# 与 ENABLE_THINKING 语义矛盾会在装配期报错(如 true + none、false + low),
# 不做"后者赢"的静默兜底——两个键说同一件事,矛盾就是配置错误
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
@@ -48,7 +61,12 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown))
# ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit,
# ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ──
# LLM__QUOTA_FULL=wait # wait(默认) | fail_fast
# LLM__QUOTA_FULL=wait # 配额满: wait(默认) | fail_fast
# ── 熔断全拒时的处置(issue #14)。单源 scope 建议 wait: 只有一个源时
# ── "停用这个源"等于"整个 scope 停服",fail_fast 会让开路期间的每次调用
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
# ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
@@ -56,7 +74,43 @@ PGW_BREAKER_BACKEND=memory # memory | redis
PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式)
PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
# PGW_TELEMETRY_SCHEMA_MODE=manual # auto | manual;三态: 不设 = 按后端派生(sqlite→auto、postgres→manual),
# # 显式设置则两侧都可覆盖。auto = 库给已存在的旧表自动 ALTER 补列;
# # manual = 库不发 ALTER,只 warning 点名缺列并打印可执行 SQL,
# # 按现有列裁剪 INSERT 继续写(遥测不会因缺列而全线丢失)。
# # 缺省为何不对称: postgres 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁,
# # 会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 await;
# # 且这类部署有 DBA、有迁移工具、讲最小权限,DDL 该由他们择时执行。
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
# PGW_TELEMETRY_PG_POOL_MAX=4 # postgres 遥测池的连接上限,须 >= 1;缺省 4。**闲时占 0 条**——
# # 池按需建连(min_size=0),不预占;这一格是忙时的天花板,不是常驻量。
# # 调参口径(以实测为准,不要按 pool_max/RTT 估算):跨内网 RTT ≈ 123ms 的
# # 实验室 PG 上,pool_max=4 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),
# # 即每条连接约 4 行/秒 —— 一次 INSERT 的实际往返比一次 `SELECT 1` 重一倍,
# # 按单次 RTT 估会乐观一倍。要放大就按这个实测值线性折算(pool_max=8 ≈ 31 行/秒)。
# # 缺省 4 在缺省 5s 预算下能吞下约 50 行的突发(余量约 1.5 倍);超预算的行被丢弃
# # 并计入 telemetry_status.dropped_rows —— 丢一条遥测好过拖垮业务调用。
# # 注意告警口径: 池饱和丢的行走**行级丢弃**,telemetry_status.degraded 保持
# # False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按
# # degraded 告警会完全看不见这一类丢行 —— 对账要两个字段一起看。
# # 什么时候该调大: 单进程遥测写入并发经常超过 4(高频短调用、批量并发),
# # 或多个 client 显式共享同一个 recorder(并发在这里汇聚,应按 client 数放大)。
# PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=5.0 # 一次遥测写入的硬预算(秒),须 > 0;缺省 5.0。同时用作建连、
# # acquire 与「准备 + 取连接 + 执行」整段的上界:超时即丢弃该行,
# # 绝不让遥测无界地挂在业务路径上。实测参考: 稳态写入 123ms、
# # 首次写入含建连 513ms —— 5s 对正常路径是极宽松的上限,它防的是
# # 池满排队与后端假死这类"不会自己结束"的等待。
# # 与之配套的两个不可配内部常量: 连接释放上界 1s(超时即 terminate)、
# # 环境级降级的冷却期 60s(到期自动重试一次,成功即恢复)。
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
# # 缺省为何是"不截断": 遥测被下游当**审计证据**用——出了问题要回答"当时到底发了什么",
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
+451
View File
@@ -1,5 +1,456 @@
# Changelog
## 1.3.3(2026-09-05)
推理从「开 / 关」升级为**档位**(issue #20)。`enable_thinking: bool | None` 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 `low/high/max`,`none` 不是它的档位——二态布尔在它上面无档可填,下游只能手写 `extra_body`,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。
**版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。** patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。
### 请先读这一条(一):五处破坏性变更
| # | 位置 | 变更 | 谁会当场断 |
|---|---|---|---|
| 1 | `ThinkingCapability` | 构造签名 `can_disable: bool``supported_efforts: tuple[Effort, ...]` | 自建能力表的调用方(**关键字与位置两种构造都断**) |
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort`(25 → 26 参) | 任何自建 recorder 实现 |
| 4 | `thinking.resolve_thinking()` | 第三参数由 `bool` 换成 `Effort`,**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 直调它的读侧代码一律断 |
| 5 | `providers.ProviderProfile` | 两个字段 `thinking_on` / `thinking_off` → 单字段 `thinking: ThinkingWire` | 自建 profile 的调用方 |
第 1 条的 `can_disable` **保留为只读派生属性**(`Effort.NONE in supported_efforts`),只读它的代码一行不用改;**构造则两种写法都断**:
| 1.3.2 的写法 | 升级后 |
|---|---|
| `ThinkingCapability(can_disable=True, evidence="…")`(库自己那张表用的就是它) | `TypeError: ... got an unexpected keyword argument 'can_disable'` |
| `ThinkingCapability(True, "…")` | `TypeError: 'bool' object is not iterable`——断在 `__post_init__` 的去重校验里,错误信息看不出真实原因 |
| 迁移写法 | `ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")` |
第 2、3 条按这两个端口的既有纪律**不设默认值**:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 `ThinkingResolution(payload, applied_effort)`——原来那个 mapping 现在是 `.payload`,多出来的 `.applied_effort` 是开了 `nearest` 映射后**真正发出去**的那一档。
### 请先读这一条(二):不改一行代码也会变的四条行为
| # | 变更 | 影响 |
|---|---|---|
| 1 | `glm-5.3` / `glm-5.3-flash` / `gemini-3.1-pro` **首次进入能力表**,且三者都登记为**关不掉推理** | **本版唯一会打断存量配置的一条。** 1.3.2 里这三个型号未登记,给它们配 `ENABLE_THINKING=false` 会按 provider 形态尽力注入并**放行**(只发一条 warning);本版在**装配期**抛 `ThinkingUnsupportedError`。并排实测:`deepseek/glm-5.3 + ENABLE_THINKING=false` 在 1.3.2 返回 `{"thinking": {"type": "disabled"}}`,在本版当场报错 |
| 2 | `openai` 段的**开启**方向由「形态未知即装配期报错」放宽为 `on_base={}` | 把任意兼容厂商挂在 `openai` 段下并配 `ENABLE_THINKING=true` 的下游:1.3.2 在装配期报错,本版放行且**一个字节都不注入**——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」) |
| 3 | `openai` 段的**关闭**方向由「形态未知即装配期报错」放宽为 `{"reasoning_effort": "none"}` | 同上但配 `ENABLE_THINKING=false` 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 `reasoning_effort` 是 OpenAI **官方**字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商 |
| 4 | 缓存 key 加入 `reasoning_effort` | 只有**新配** `REASONING_EFFORT` 的源冷启动一次;只配 `ENABLE_THINKING` 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对) |
第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 `extra_body` 那条绕过库的路。
**`qwen` / `deepseek` / `minimax` 三段的注入形态逐字未变。** 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × `ENABLE_THINKING` 三态 = 300 种组合)显示,本版与 1.3.2 的差异**只有上表第 1、2、3 条**。`minimax` 的「开」尤其值得点名:它维持 `{"reasoning_effort": "medium"}` 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时**不推理**(5/5 轮),把它改成「不注入即为开」会让存量 `ENABLE_THINKING=true` 的调用静默停止推理。
### 新增能力
| 新增 | 说明 |
|---|---|
| 八档 `Effort`:`none` / `auto` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` | 封闭词汇,取四家参考实现共同收敛的那一套。`none` = 要求不推理(与「不表态」是两回事),`auto` = 要求推理但不指定强度 |
| `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT` | 源级默认档。`ENABLE_THINKING` 保留,降为它的语法糖(`true``auto``false``none`、缺省 ≡ 不表态);两键语义矛盾(如 `true` + `none`)在**装配期**报错,不做「后者赢」的静默兜底 |
| `{SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK` | `error`(缺省,报错)或 `nearest`(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 `medium → max` 在部分模型上是数倍账单 |
| `chat(reasoning_effort=...)` | 请求级覆盖,优先级高于源级;裸字符串会在入口归一 |
| `LLMResponse.applied_effort` | 本次**实际**跑在哪一档(开了 `nearest` 时与请求档分叉)。字段追加在末尾,既有字段只增不改名 |
| 四个新 provider 段 `zhipu` / `moonshot` / `anthropic` / `google` | 连同 1.3.2 已有的 `qwen` / `deepseek` / `minimax` / `openai` 共**八段**。四段都是新增,不改变任何存量配置的行为 |
| 能力表由 **5 条扩到 24 条** | 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款 |
| `kimi-k3` **首次登记**为可关闭 | 它在 1.3.2 未登记(配 `false` 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 `none` 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 `mandatory:false` 是对的,官方档位表没列 `none` 只是没列 |
| 包根新增导出 `Effort` / `EFFORT_ORDER` / `ThinkingWire` / `ThinkingResolution` | 深路径 import 会被内部重组打断,一律从 `polygateway` 包根取 |
档位不支持时**报错必带可执行替代**:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 `extra_body`——而那正是 issue #20 的成因。
### 遥测:第 26 个 INSERT 字段 `reasoning_effort`
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,`NULL` 表示调用方**没表态**;它与 `'none'`(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。
**成功行与失败行不是同一把尺子。** 开了 `EFFORT_FALLBACK=nearest` 的源上,成功行记的是**映射后的实发档**(读 `response.applied_effort`);失败尝试没有响应、实发档无从得知,记的是**请求档**。故 `GROUP BY reasoning_effort` 不带 `error IS NULL` 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 `nearest` 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 `NULL`
补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE`,两端 DDL 与 `COLUMNS` 同源。**manual 档的下游会看到一处文案变化**:旧表的缺列告警会多点名 `reasoning_effort` 这个维度,并附上对应的 `ALTER TABLE ADD COLUMN` 语句。
### 能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定
`DEFAULT_CAPABILITIES` 共 24 条,每条 `evidence` 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。**读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。** 未能实测的 7 条与原因:
| 模型 | 未覆盖的原因 |
|---|---|
| `claude-opus-5``claude-sonnet-5` | 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;`none` 档还额外依赖网关把 `reasoning_effort=none` 转成 `thinking` 关闭形态,同样未经验证 |
| `gemini-3.1-pro` | 该渠道本型号上游报错(`bad_response_status_code` / `openai_error`),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架**仍未决**,本版不选边 |
| `gpt-5.4` | 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 `gpt-5.5` 已实测且与清单逐字相符,可作旁证但不是本型号的证据 |
| `glm-5``glm-5.1``glm-5.2` | 请求这三个型号时,渠道 5/5 轮把流量路由到 `glm-5.3`(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废 |
`glm-5.2` 的下游风险要单独说:在这条渠道上给它配 `none`,库会照文档推定放行,而真正服务请求的 `glm-5.3` **关不掉推理**;运行期对账会喊,但那是事后。
另有两条与实测相关的收获值得下游知道:同一批实测发现 `zhipu` / `moonshot` 这条渠道**不校验档位值**(未登记的 `medium` 也照单收下并返回 200),故「网关没报错」在这两家上**不构成**「该档受支持」的证据;而 `openai` 那条会校验(清单外的 `max` / `minimal` 被上游 400 拒)。
### 已知限制:`auto` 不等于「强制开推理」(issue #21)
`reasoning_effort=auto`(含它的语法糖 `ENABLE_THINKING=true`)在 `on_base={}` 的三个 provider 段(`openai` / `anthropic` / `google`)上表达的是「**用模型自己的默认档**」,库不注入任何字节。若某模型默认就不推理,这个配置**既不报错也不开推理**。正解是让 `auto` 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。
与之相连有一处**刻意的不一致**,请勿误读:`DEFAULT_CAPABILITIES``MiniMax-M3``supported_efforts` **不含 `auto`**(实测结论——它的默认档不推理),而 `minimax` 段的 wire 会为 `auto` 注入 `{"reasoning_effort":"medium"}` 并被放行。`resolve_thinking` 的 Phase 5 对 `auto` 无条件放行(`auto` 不是写进 `effort_key` 的取值,而是「不写 `effort_key`」),**能力表拦不住这条路**;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。
## 1.3.2(2026-08-28)
**本版不改库代码。** `tools/``tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
`tools/telemetry_retention.py` 此前删哪张表,取决于连接的 `search_path`——它的首项是 `"$user"`,所以**换个角色跑同一条命令,目标可能就换了一张表**。脚本会把解析到的限定名打出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
新增可选参数 `--table <schema>.llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`
| 情形 | 行为 |
|---|---|
| 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 |
| 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE |
| 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) |
| 显式指定的是分区表 | 仍退出 **3** 让路给 `DROP PARTITION`,语义未变 |
退出码契约未新增也未改动。**建议 cron 一律带上 `--table`**:那一行配置从此自己说明删的是哪张表。
### 测试隔离:从"事后观测共享表"改成"权限上做不到"
issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张**三个迁移项目也在写**的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。
行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表**没有任何授权**,`search_path` 万一落空就是 `permission denied` 而不是"但愿有断言发现"。共享表 `llm_calls` 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。
对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。
## 1.3.1(2026-08-26)
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
**版号是 patch,但本版含三处会影响下游的变更**——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。
### 请先读这一条(一): `polygateway.providers` 的深路径 import 断了
推理相关的**六个符号**从 `providers.py` 移进新模块 `polygateway.thinking``from polygateway.providers import ...` 引用其中任何一个,升级后当场 `ImportError`:
| 从 `providers` 断掉的符号 | 改成(**推荐**) | 或 |
|---|---|---|
| `ThinkingCapability``ThinkingUnsupportedError` | `from polygateway import ...` | `from polygateway.thinking import ...` |
| `get_capability``register_capability``resolve_thinking` | `from polygateway import ...` | `from polygateway.thinking import ...` |
| `DEFAULT_CAPABILITIES` | `from polygateway.thinking import DEFAULT_CAPABILITIES` | — |
**前五个请改用包根 import**: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 `ThinkingObservation`,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。`DEFAULT_CAPABILITIES` 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。
`providers.py` 保留的 `ProviderProfile` / `DEFAULT_PROFILES` / `get_provider` / `register_provider` 逐字未动。
拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。
### 请先读这一条(二): `TelemetryRecorder.record_llm_call` 从 24 参变 25 参
新增 keyword-only 参数 `thinking_observation: str`,**且按该 Protocol 的既有纪律不设默认值**(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。**自定义 recorder 实现必须同步补这个参数**,否则调用时 `TypeError`。库自带的 `SQLiteRecorder` / `PostgresRecorder` 已同步,不受影响。
`TelemetryRecorder` 之外的端口逐字未变;`TelemetryStatusProvider` 不受影响。
### 请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来
2026-08-25 实测: M3 非流式开启推理时 `completion_tokens` 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 `reasoning_content` 正文、也没有 `usage.completion_tokens_details`——**钱花了,东西一个字都拿不到**。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 `unknown`,并按 `(模型, 方向)` 发**一次** warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。
要拿到推理正文,该模型请走**流式**路径(实测 185 字符正文完整)。
### 诊断纠正: 不是模型不推理,是 MiniMax 停报 `completion_tokens_details`
issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 `httpx` 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
真正变的是 **MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**(qwen 与 deepseek 在同一网关、同一 key 上照常返回),`reasoning_tokens` 因此恒为 `None`。而库把「推理是否发生」全押在这一个字段上,于是**手里握着 185 字符推理正文,却对外报告「没推理」**。
缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`
### 三态,以及它为什么不能折叠成布尔
`LLMResponse.thinking_observation`(类型 `ThinkingObservation`,`StrEnum`,缺省 `unknown`)由多信号裁定,判据按**证据硬度**排序:
| 值 | 判据 |
|---|---|
| `observed` | 推理正文 `thinking` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | `reasoning_tokens == 0`——上游明确上报本次未推理,是正面证据 |
| `unknown` | 两个信号双缺,判不出来 |
**`unknown``absent` 不是一回事**,把前者折叠进后者正是本次故障的病根。`unknown` 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 `unknown` 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。
对下游的口径变化: 统计「未推理」**不要再写 `reasoning_tokens IS NULL OR = 0`**,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 `thinking_observation` 分组,`unknown` 独立成一档。
### 声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警
推理能力表(`can_disable`)是静态声明,而静态声明**必然过期**——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。
本版在 transport 拿到结果处做一次比较,矛盾即 warning(**不抛错**——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):
| 请求方向 | 观测 | 告警内容 |
|---|---|---|
| 关闭 | `observed` | 关闭请求未被满足。能力表已登记则点出 `evidence` 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入 |
| 开启 | `absent` | 已注入开启参数,上游却明确上报未推理 |
| 开启 | `unknown` | 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传 |
`关闭 × unknown` 与「调用方没提要求」两类**有意不表态**: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 `(源, 模型, 方向)` 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。
**保障的覆盖面必须说清楚**: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 `observed` 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。
### 遥测新增一列 `thinking_observation`
`llm_calls` 加一列 `thinking_observation TEXT`(可空,取值 `observed` / `absent` / `unknown`),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 `INSERT` 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。
照 README「生产部署 DDL 模板」部署的下游**不需要改模板**: 那份模板用 `LIKE llm_calls_seed` 从库自己建出的表派生列,与 `telemetry/schema.py` 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。
### 其他
- 缓存回放的 `thinking_observation``ThinkingObservation` 枚举实例而非裸字符串: JSON 复活出来的是 `str`,与字段注解分叉,`CacheMW._rehydrate` 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)**降级为 `unknown` 并单独告警,响应内容照常复活**——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
- M3 的推理能力 `evidence` 刷新到 2026-08-25 复测。`can_disable` **仍为 `True`**(`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;`enable_thinking``thinking={"type":"enabled"}` 对该模型无效,只有 `reasoning_effort` 是真开关。
- `TransportResult` 同步新增该字段并由 `RetryMW` 透传;裁定在 `openai_compat` 的流式与非流式**两条**组装路径各做一次。
- 遥测的新列只经 `TelemetryEmitter._record` 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 `str`——`StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: `LLMResponse` 无运行时校验,下游填裸 `str` 完全自然,而直接取 `.value` 会抛异常并被降级路径吞成**丢掉整行**遥测;域外取值同样只降级记 `unknown` 并单独告警,不拿整行当代价。
## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
根因不是"asyncpg 的默认 `min_size=10` 太大"这一条,而是四层叠加,只改默认值会留下三层:
| # | 缺陷 | 本版 |
|---|---|---|
| ① | 库对自己的资源占用从未表态 —— `create_pool(dsn, timeout=10)` 继承第三方默认值,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 | `min_size=0` + `max_size` 可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4)+ 每次写入硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5.0s) |
| ② | 判死判据挂在"**哪一步**失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 `too many clients`(下一秒可能就好) | 判据改挂"失败是**什么性质**",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试 |
| ③ | 降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 | 进入/恢复各一条日志 + 降级期间节流复述 + `client.telemetry_status` 只读快照 |
| ④ | "多个 client 共享一个 recorder"这条正道是坏的(第一个 `aclose()` 就把共享的 recorder 弄死),所以下游只能退回"每个 client 各占一份" | 全库统一"谁建的谁关"纪律,共享路径打通 |
真实实验室 PG 上的连接数实测,一眼可见差别: **修复前**建完 recorder 就是 **10** 条;**修复后**建完 recorder **0** 条 → 一次写入后 **1** 条 → 20 行并发后 **4** 条(= `pool_max`)→ `aclose()` 后回到 **0**
### 请先读这一条(一): 最低 Python 版本提到 3.12,3.11 的部署装不上
`requires-python``>=3.11` 改为 `>=3.12`。这是本版四条要点里**唯一会让下游装不上**的变更——仍在 3.11 上的部署执行 `pip install` 会被 pip 直接拒绝,不是运行时报错,是装不了。升级 Python 或钉住 `polygateway<1.3` 二选一。
抬版本不是顺手做的: 本版的写入预算依赖 `asyncio.timeout`,而 3.11.0 / 3.11.1 的 `uncancel` 有已知缺陷,继续支持 3.11 就得退回 `wait_for` 并绕开那个缺陷。取舍是缩小支持面换掉一整块补丁代码。同批把三处泛型函数改成 PEP 695 语法(`def f[T](...)`,该语法在 3.11 是 `SyntaxError`)。
### 请先读这一条(二): 遥测的常驻连接数会从 `10 × client 数` 掉到 0,监控曲线会突变
这是纯改善,但**曲线会跳**,不要误判为故障: 连接不再于装配期预占,而是第一次写入时才建、忙时最多 `PGW_TELEMETRY_PG_POOL_MAX` 条(缺省 4)、空闲超过回收期后归 0。代价是首次写入多付一次建连(实测 ≈390ms,相对一次秒级 LLM 调用可忽略),稳态写入无差异(实测 123ms)。
`pool_max` 的调参口径请按实测折算,**不要按 `pool_max / RTT` 估算**——那会乐观一倍: 跨内网 RTT ≈ 123ms 的实验室 PG 上,`pool_max=4` 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s),因为一次 `INSERT` 的实际往返比一次 `SELECT 1` 重。缺省 4 配缺省 5s 预算能吞下约 50 行的突发,余量约 1.5 倍;超预算的行被丢弃并计入 `telemetry_status.dropped_rows`——丢一条遥测好过拖垮业务调用。多个 client 共享同一个 recorder 时并发在这里汇聚,应相应放大。
### 请先读这一条(三): `aclose()` 不再关闭注入进来的组件
新纪律是**谁建的谁关,注入的一律不碰**: `from_env()` / `from_settings()` 自建的 transport / recorder / limiter / breaker / cache 照常被 `aclose()` 关掉;经构造函数**注入**进来的则一律不碰,由注入方自己关。`RedisCache` 同款(注入的 redis 客户端不再被误关)。
这修正的是一次越权——共享同一个 recorder 的多个 client 里,第一个 `aclose()` 会把其他 client 还在用的 recorder 弄死。但**若你的代码依赖了"注入之后由 client 代关",升级后会漏关**,请自行补上关闭。同一批还修掉了反方向的泄漏: 自建的 redis limiter / breaker 客户端此前**从来没有人关**(`aclose` 压根不持有它们的引用),现在会被关。
### 请先读这一条(四): 直接构造 `GatewaySettings` 的代码要补两个参数
`GatewaySettings` 新增 `telemetry_pg_pool_max: int``telemetry_pg_write_timeout_s: float` 两个**无默认值的必填**字段。走 `from_env()` / `from_settings()` 的调用方不受影响(两个新键都是可选的,env 装配路给缺省 4 与 5.0);**直接构造 `GatewaySettings(...)` 的代码——测试装配、配置改写脚本——升级后不补参数会当场 `TypeError`**。
这不是疏忽而是既有纪律: 相邻的 `telemetry_auto_migrate` / `telemetry_text_cap` 同样无默认值,缺省规则只写在 `_load_*` 一处,不与字段签名漂移(P4 显式优于隐式)。写默认值在此也不可能——这两个字段后面还跟着四个无默认值字段,加了就是 `TypeError: non-default argument follows default argument``dataclasses.replace(settings, ...)` 一路不受影响。
### 遥测失败的三分判据
判据两句话:**致命 = 失败原因完全在进程内部且不可变**;**行级 vs 环境级看"失败与这一行的数据有没有关系"**。
| 档 | 覆盖 | 处置 |
|---|---|---|
| 配置级致命 | DSN 不可解析(`ClientConfigurationError`)、建池参数非法 | 永久 no-op + 一条 **error**(这是人配错了,不是 warning) |
| 环境级不可用 | 连接类 `08` / 资源不足 `53`(含 53300 too many connections)/ 管理干预 `57` / 认证 `28` / 库不存在 `3D`,以及 `42501` 无权限、`42P01` 表不存在;网络类异常;**超时类异常仅在准备期路径可达**(写入期的超时先被 `record_llm_call``except TimeoutError` 接住,按行级丢弃);表确定不存在且建不出来 | **冷却 60s 后自动重试一次**,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启 |
| 行级拒绝 | 其余数据与约束类错误(`22`/`23` 等),外加**唯一具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级 |
`42703` 之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。
### 新增公共 API
| 名字 | 内容 |
|---|---|
| `GatewayClient.telemetry_status` / `EmbeddingClient.telemetry_status` / `OcrClient.telemetry_status` | `TelemetryStatus \| None` 只读属性。`None` = 未启用遥测,或注入的 recorder 不提供状态 |
| `polygateway.TelemetryStatus`(顶层导出) | frozen dataclass: `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s`。下游可据此对账或告警,不必再人工比对行数 |
| `ports.TelemetryStatusProvider` | 新增的**独立**可选端口。`TelemetryRecorder` **逐字未变**——它是 `@runtime_checkable`,往里加成员会让所有只实现 `record_llm_call` 的对象当场不再满足协议,下游的同款 `isinstance` 断言升级即断 |
### 其他
- 两个新配置键 `PGW_TELEMETRY_PG_POOL_MAX`(缺省 4,须 ≥ 1)与 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(缺省 5.0,须 > 0)。`GatewaySettings` 相应新增两个**无默认值的必填**字段,与相邻三个遥测键(`telemetry_auto_migrate` / `telemetry_text_cap` / `telemetry_sqlite_path`)完全一致——上面那两个"缺省"只存在于 env 装配路(`_load_*` 函数),直接构造 `GatewaySettings` 的调用点必须补这两个参数,见"请先读这一条(四)"。`PostgresRecorder``pool_max` / `write_timeout_s` 是 keyword-only **必填**参数(直接构造 recorder 的调用点需补,不传即 `TypeError`)。
- `PostgresRecorder.aclose()` 现在是**有界且终局**的: 走 `asyncio.wait_for` + 超时 `terminate()`(`Pool.close()` 在 in-flight 连接未释放时会无限等,asyncpg 自己的文档就建议加 `wait_for`);关闭后写入短路且**不再复活**——此前关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入外部池的调用方以为自己管着全部连接、实际早已不是。
- 降级日志的**级别由是否致命决定**: 配置级致命(DSN 写不对)发 **ERROR**——人配错了、本进程内不会自愈,运维必须看见;其余(后端挂了、权限被收、表被删)发 WARNING——外部状态,冷却到期会自己重试。级别只在 `TelemetryStatusTracker` 一处决定,两个 recorder 共用。
- 对账请**同时看 `degraded``dropped_rows`**: 写入因本地池饱和超出预算被丢时走的是行级丢弃,`degraded` 保持 `False`(后端并没有挂,是本进程并发超了),只有 `dropped_rows` 增长。只按 `degraded` 配告警会完全看不见这一类丢行——而它恰是 `PGW_TELEMETRY_PG_POOL_MAX` 配小了的唯一信号。
- SQLite 遥测初始化失败后终于有日志了。此前 `sqlite.py` 初始化失败直接 `return`,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版**只做可见性**,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
- 写入路径不再用 `async with pool.acquire(...)``Pool.release()` 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
## 1.2.4(2026-08-20)
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
**缺省是 `fail_fast`,即今天的行为**,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,`MAX_ATTEMPTS=8` 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 `wait` 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长——上限是 `STALL_WINDOW_S`(缺省 300 秒)。**但 `wait` 并不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`,所以密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而不是等满窗口后的 `stalled`;两者哪个先到取决于 `MAX_ATTEMPTS` 与冷却时长、`STALL_WINDOW_S` 的相对大小。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不当场死"。
### 请先读这一条: `retry_after_s` 在半开状态下的取值变了(缺省档同样生效)
`retry_after_s` 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:**距离确定可再试的时刻还有多久**。健康与准入允许 → `0.0`;开路 → 剩余冷却;**半开(探针在途)→ `0.0`**,因为探针随时可能出结果,不存在确定的时刻——而 `0 = 可立即重试` 本就是这个字段的既有约定。
变更点在半开:此前返回的是**探针租约剩余**。那是个死锁保护参数,派生自 `max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5)`,与"这个源多久能恢复"没有任何因果关系。`TIMEOUT_S=300` 的部署里它是 600 秒,而冷却期只有 60 秒。**照它延期重投的下游,等的是一个物理上无意义的数。**
更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 `set_until` 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——**本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。
同批统一了两个后端在**六个出口**上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
### 其他
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
## 1.2.3(2026-08-19)
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
同一版里,issue #12 补上这条边界的另一半——**删数据**,并把它落成三样**手段**: 遥测正文的可配置上限、`tools/` 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样**没有一样改变缺省行为**——不设 `PGW_TELEMETRY_TEXT_CAP` 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 `DELETE`/`DROP` 权限: 保留期是 `tools/` 下的独立脚本,库不 import 它。
### 请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的
1.2.1 的 README 给的 RLS 模板把**写侧**也绑在了 `app.tenant_id` 这个 GUC 上:
```sql
-- 1.2.1 的模板,有缺陷,勿用
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
`PostgresRecorder` 用**一个连接池给所有租户**写遥测,源码里从不发 `set_config('app.tenant_id', ...)`——库既拿不到也不该猜租户上下文该怎么设。于是 `WITH CHECK` 里的 `current_setting` 恒为 NULL、等值比较恒不为真,**库的每一条 `INSERT` 都被 policy 拒绝**。而遥测的失败方向是静默降级,所以表现不是报错,是**整张表零行**——业务调用一切正常,不看日志根本发现不了。
照抄过就请现在查这两条:
| 查什么 | 中招的样子 |
|---|---|
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
本版的新模板把写侧改为 `WITH CHECK (true)`,隔离交由**读侧**的 `USING` 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。
### 破坏性变更(五项)
| # | 变更 | 影响与应对 |
|---|---|---|
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
| ④ | `GatewaySettings` 再新增**必填**字段 `telemetry_text_cap: int \| None` | 同 ③,只影响直接构造这条路。`None`(不截断)是**取值**而不是默认值——字段本身没有默认值;`<= 0``__post_init__` 直接 `ValueError`,不会被当成"不截断" |
| ⑤ | `TelemetryEmitter` 新增 **keyword-only 必填**参数 `text_cap` | 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 `TypeError`。同样**故意不给默认值**: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 `text_cap` 全汇流到这里,而 `GatewaySettings` 那道只管 env 一条路 |
### 新增
- **`PGW_TELEMETRY_SCHEMA_MODE`(可选键,值域 `auto` / `manual`)**,**三态**:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则**有意不对称**——`postgres``manual`,`sqlite``auto`。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 `runs/*.db`),没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
- **公共函数 `telemetry_schema_sql(backend) -> str`**(已进顶层 `__all__`):返回可直接粘进迁移文件的完整脚本——注释头 + `CREATE TABLE IF NOT EXISTS`(全量列)+ 各补列语句。PG 变体带 `ADD COLUMN IF NOT EXISTS`,整段**可重复执行**;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 `backend``ValueError`
- **manual 档的缺列告警**逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且**只在准备期发一次**,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
| 手段 | 内容 |
|---|---|
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments``name``content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
### 变更
- **Postgres 的写入去掉了冲突目标**:`ON CONFLICT (call_id) DO NOTHING``ON CONFLICT DO NOTHING`。普通表上语义**逐字等价**(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 `(call_id)` 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区后主键变成 `(call_id, created_at)`,该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 `INSERT OR IGNORE` 本就无目标,未动。
- **manual 档按现有列裁剪 `INSERT`**。这不是可选增强而是关掉 `ALTER` 的前提:旧表缺列时若仍发全量 `INSERT`,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
- **schema 常量收敛为单一事实源** `telemetry/schema.py`(内部模块):列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是**正确性**而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
### 不变
- **manual 档仍然建表**。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 `to_regclass` 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
- **auto 档行为与从前逐字相同**,包括补列失败时**不裁剪**:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
- 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
- **遥测缺省不截断**: 不设 `PGW_TELEMETRY_TEXT_CAP` 时落库正文与今天逐字节相同。`digest_messages`(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断**只产出新对象、绝不就地修改**——`digest_messages` 对非 list 的 `content` 是原样透传**同一个 dict 对象**,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 `build_cache_key` 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
- embedding 与 OCR 两条链路各自既有的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系;多模态 `image_url` 早已是 sha256 摘要,不受 cap 影响。
### 库对下游数据库的承诺(Expand/Contract,本版成文)
以下五条此前已被实现满足,但从未写成承诺。本版起它们是**承诺**:新列**只增不删不改名**且一律追加在既有列之后;新列必**可空**或带**非易失常量默认值**(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);`INSERT` **永远显式写出列名**;库**从不 `SELECT *`**、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的**冲突处理不绑定具体约束**。
合起来它们保证:你可以自行给 `llm_calls` 加列、加索引、挂 RLS,乃至把它建成 `PARTITION BY RANGE (created_at)` 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,`research-wiki/` 不在 sdist 内。
同一条边界的另一半是**删数据**: 库不持有 `DELETE`/`DROP` 权限,保留期与访问控制以 README 模板加 `tools/` 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`(那是 DDL,同样不触发不可变性触发器)。分区在这里**不可替代**,不是性能偏好。
### 升级提示
-`from_env()` / `from_settings()` 装配的下游**无需改代码**;Postgres 下游升级后建议执行一次 `python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))"` 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
- 直接构造 `SQLiteRecorder` / `PostgresRecorder` 或直接构造 `GatewaySettings` 的调用点必须补上新参数/新字段,否则 `TypeError`
- **截断不需要任何升级动作**: 不设 `PGW_TELEMETRY_TEXT_CAP` 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
- 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
- README 的安装 pin 由 `>=1.2.1,<2` 收紧为 `>=1.2.3,<2`。按旧 pin 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关**,而包内那份 README 描述的正是它们。
## 1.2.1(2026-08-18)
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。
### 新增
- **四个公共方法各增两个 keyword-only 参数 `tenant_id``meta`**,都带默认值 `None`,**既有调用点零改动**: `GatewayClient.chat()``EmbeddingClient.embed()``OcrClient.recognize_text()``OcrClient.parse_layout()`。issue 只诉求前两条链路;OCR 经同一个 `TelemetryEmitter` 写**同一张表**,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
| 列 | Postgres | SQLite |
|---|---|---|
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
- **老表经现有 `_BACKFILL` 机制自动补列**(先探测再 `ALTER`,失败只逐行降级),补列后**老行的 `tenant_id` 读出是空串而非 NULL**。这个区别是刻意的: PG 的 RLS `USING` 表达式返回 false **或 null** 的行都不可见、且静默跳过不报错,所以 NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**;哨兵空串则显式可查,`COUNT(*) WHERE tenant_id = ''` 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
- **`TelemetryRecorder.record_llm_call` 由 22 字段扩为 24**(`inspect.signature` 实测),`ChatRequest` 同步新增两个带默认值的字段。`meta``json.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False)` 序列化,空 dict 落 `'{}'` 而非 NULL。
### 校验规则(超限报错,不静默丢弃)
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
| 项 | 规则 |
|---|---|
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
| `meta` 键数 | ≤ **16** |
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
| `meta` 值 | 仅 `str` / `int` / `float` / `bool`,嵌套需调用方自行序列化;字符串值 ≤ **256** 字符;`float` 必须有限,`nan` / `inf` 报错(它们不是合法 JSON,PG 的 JSONB 会拒收) |
报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。**超限一律报错**,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。
### 不变
- **`tenant_id``meta` 都不进缓存 key**。租户级的缓存隔离由既有的 `cache_namespace` 负责,重复进 key 只会让全部存量缓存冷启动;且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
- 既有 22 列的列名与列序、`ON CONFLICT (call_id) DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**,下游 `except` 写法不受影响。
- 缓存命中行与终态失败行同样带维度,且读的是**本次** `request` 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
### 边界: 库只交付列,RLS 与索引由下游执行
**库不会执行 `ENABLE` / `FORCE ROW LEVEL SECURITY`,也不会建任何索引。** 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 `CREATE POLICY`(并建 `(tenant_id, created_at)` 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 `tenant_id` 等值谓词,它必然是前导列);**不执行则 `tenant_id` 只是一个可查、可过滤的普通列,没有任何数据库层强制**。
不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。
RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。
### 升级提示
- **升级无需任何代码改动**: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 `{}`
- README 的安装 pin 由 `>=1.2,<2` 收紧为 `>=1.2.1,<2`。按 `>=1.2,<2` 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.0 就没有租户维度**。
- README 的配置参考表此前漏列了源级 `MISSING_DONE``EXTRA_BODY`(正文别处却引用了后者)、`{SCOPE}__QUOTA_FULL`、embedding 专用键、`PGW_CACHE_BACKEND``memory` 档与三个可选 `PGW_*` 键,本版按 `config.py``_SOURCE_FIELDS``_load_pgw` 逐项补齐。代码零变更。
## 1.2.0(2026-08-16)
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
根因是三条留存通道同时为空: `_status_to_error` 手上握着 `body_text` 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 `str(exc)`,即 message——所以**只给异常加字段并不能让它进遥测表**,必须两者都做。
### 新增
- **四分类错误新增 `body_text` 字段**(加在 `PolyGatewayError` 基类): 非 2xx 响应体的摘要。与 `ResultInvalidError.raw_text` 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(`GatewayUnavailableError` 一族)恒为空串: 它们没有单一响应体可言。
- **同一份摘要同时进入异常 message**,故 SQLite/Postgres 遥测的 `error` 列里直接可查,下游不必为此单独埋点。
### 行为变更
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
### 不变
- **状态码 → 错误分类的映射逐条未动**(ARCHITECTURE §6.2 表),`retry_after_s` 解析、429 免重试预算、`insufficient_quota` 细分全部保持——429 的类型判定仍解析**未截断的原文**,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 `force_open`
- 异常类型树、`str(exc)` 之外的字段、遥测 22 字段与列序、DDL 全部未变。**错误面零变更**,下游 `except` 写法不受影响。
- 400 仍按确定性失败处理(不重试不换源)。**但请注意**: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 `prompt_tokens=0` 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 `body_text` 现在给了下游自行区分的判据。
### 升级提示
README 的安装 pin 由 `==1.1.*` 改为 `>=1.2,<2`。**仍按 `==1.1.*` 安装的下游会静默停在 1.1.2**,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。
- 打包元数据补齐: `readme``[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
## 1.1.2(2026-08-07)
Postgres 遥测撞上建表权限就整体判死的问题(issue #9)。**最小权限部署会静默丢掉全部遥测**: 应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,初始化的 `CREATE TABLE IF NOT EXISTS` 被拒 → recorder 永久 no-op,业务调用一切正常,只留一行 warning。下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用耗时/token/成本因此全部丢失,且事后无法补回。
+7 -2
View File
@@ -9,7 +9,7 @@
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree``~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
- **技术栈**: Python 3.11+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`
- **技术栈**: Python 3.12+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`
## 2. 常用命令
@@ -91,11 +91,15 @@ make ci # 只读验证(check + test)
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
| 9 | **建 Release + 挂仓库 + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);挂仓库走 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`(**2026-08-16 实测返 201 可用**,此前记录的"该实例 link API 返 404、只能网页手动"已过时);随后打开包页面确认有正文与仓库链接 |
> [!CRITICAL]
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
@@ -109,6 +113,7 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
## 5. 项目结构
+16 -3
View File
@@ -1,21 +1,34 @@
.PHONY: install test lint format check ci wiki wiki-check
.PHONY: install test lint format check ci wiki wiki-check shared-table-gate
ENV := PolyGateway
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
# 留着它是因为字面量回归最常见、也最便宜拦。
shared-table-gate:
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
echo ""; \
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
exit 1; \
fi
install:
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
test:
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
lint:
lint: shared-table-gate
conda run -n $(ENV) ruff check src/ tests/ --fix
conda run -n $(ENV) lint-imports
format:
conda run -n $(ENV) ruff format src/ tests/
check:
check: shared-table-gate
conda run -n $(ENV) ruff format --check src/ tests/
conda run -n $(ENV) ruff check src/ tests/
conda run -n $(ENV) lint-imports
+286 -13
View File
@@ -13,17 +13,22 @@
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 22 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 26 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded``dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
**降级方向是铁律**:缓存/遥测后端掉线 → 静默降级(warning);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)
## 安装
@@ -31,7 +36,7 @@
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]==1.1.*"
"polygateway[redis,postgres,structured]>=1.3.0,<2"
```
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -43,7 +48,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py
| `structured` | json-repair | 结构化输出的修复策略 |
| `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) |
要求 Python ≥ 3.11
要求 Python ≥ 3.12
## 快速开始
@@ -81,7 +86,7 @@ async def main() -> None:
await client.aclose() # 归还连接与治理后端资源
```
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
### 3. OCR 与 Embedding
@@ -112,6 +117,256 @@ except RequestRejectedError:
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
```
### 5. 多租户与自定义维度
```python
resp = await client.chat(
messages,
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
)
```
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
## 遥测表 schema 与升级纪律
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
| 库会发 | 库不发 |
|---|---|
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
| 取值 | 含义 |
|---|---|
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres`**manual** |
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
| 表状态 | `auto` | `manual` |
|---|---|---|
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
无论哪档,遥测的失败方向都是**静默降级**:缺列、补列失败、写入失败都只 warning,绝不冒泡打断业务调用。
### 自取建表脚本
`telemetry_schema_sql` 输出与库运行时执行的 DDL **同源**(同一份常量),照它建完表,库探测到的列就是齐的:
```python
import polygateway
print(polygateway.telemetry_schema_sql("postgres")) # 或 "sqlite";非法值抛 ValueError
```
```bash
# 直接落成迁移文件:注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
> migrations/001_llm_calls.sql
```
PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**(它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故请挑低峰);SQLite 没有该语法,脚本以注释标明"仅当该列不存在时执行"。注意这与库**内部**执行的 ALTER 是两份文本:库侧一律先探测后 ALTER,不用 `IF NOT EXISTS`,正是为了在稳态下一条排他锁都不取。
### Expand/Contract 承诺
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
| 承诺 | 你可以据此做什么 |
|---|---|
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
## 生产部署 DDL 模板(PostgreSQL)
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
| # | 锚点 | 做什么 |
|---|---|---|
| 1 | `roles` | 建三角色并授 schema 级权限 |
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
| 5 | `immutable` | 触发器兜底(只防误操作) |
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
### 1. 三角色
| 角色 | 拿到什么 | 谁在用 |
|---|---|---|
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
<!-- pg-template:roles -->
```sql
CREATE ROLE polygateway_owner NOLOGIN;
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
```
### 2. 分区表
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
```bash
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
| psql "$PGW_TELEMETRY_PG_DSN"
```
<!-- pg-template:table -->
```sql
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
CREATE TABLE llm_calls (
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
) PARTITION BY RANGE (created_at);
DROP TABLE llm_calls_seed;
ALTER TABLE llm_calls OWNER TO polygateway_owner;
```
<!-- pg-template:partition -->
```sql
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
```
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'``retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at``DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
### 3. 权限与不可变性
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
<!-- pg-template:grants -->
```sql
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
GRANT SELECT ON llm_calls TO polygateway_report;
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
```
<!-- pg-template:immutable -->
```sql
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
END;
$$;
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
```
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
`DROP PARTITION``DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
### 4. 行级安全与多租户隔离
> **照抄过 1.2.1 那份 RLS 模板的部署请先查一遍**:那份模板把**写侧**也绑在 `app.tenant_id` 这个 GUC 上,而库从不设这个 GUC,于是它的每一条 `INSERT` 都被 policy 拒绝——遥测的失败方向是静默降级,表现不是报错而是**整张表零行**。用能绕过 RLS 的角色(superuser 或带 `BYPASSRLS`)执行 `SELECT count(*) FROM llm_calls;`,并在应用日志里搜 `Postgres 遥测写入失败(丢弃该行):`。下面这份是修正后的模板。
<!-- pg-template:rls -->
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
WITH CHECK (true);
CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
<!-- pg-template:index -->
```sql
CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`
**写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
四个陷阱,每一个的失败形态都是**静默的**:
| 陷阱 | 后果 |
|---|---|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
| 读侧 policy 漏写 `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
### 5. 库本身需要的最小权限
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
| 库会发的语句 | 需要什么 |
|---|---|
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
### 6. 合规下游的推荐配置
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
```dotenv
PGW_TELEMETRY_BACKEND=postgres
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
```
| 层 | 配置 |
|---|---|
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--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 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
### 7. SQLite 侧的保留期
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
## 错误模型(四分类)
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
@@ -125,6 +380,8 @@ except RequestRejectedError:
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
### 哪些异常会到达调用方
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
@@ -146,12 +403,28 @@ except RequestRejectedError:
| 键形态 | 作用 |
|---|---|
| `{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/TRUST_ENV |
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/REASONING_EFFORT/EFFORT_FALLBACK/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` | 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` 必填(分批是行为关键,不设默认) |
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
| `PGW_CACHE_BACKEND` | `none` / `redis`(`PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`) |
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none``PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text``response``thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
| `PGW_TELEMETRY_PG_POOL_MAX` | 可选正整数(缺省 4):Postgres 遥测池的连接**上限**。池按需建连,闲时占 0 条,这一格是忙时天花板而非常驻量。调参按实测折算而非按 `pool_max / RTT` 估算——跨内网 RTT ≈ 123ms 上 `pool_max=4` 实测约 15.6 行/秒(一次 `INSERT` 的往返比一次 `SELECT 1` 重一倍);多个 client 共享同一 recorder 时并发在此汇聚,应相应放大 |
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 可选正数(缺省 5.0):**一次遥测写入的硬预算**,同时用作建连、`acquire` 与「准备 + 取连接 + 执行」整段的上界;超时即丢该行,绝不让遥测无界地挂在业务路径上 |
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
熔断的设计前提是"这个源坏了,把流量导到别的源"。**只配了一个源时这个前提不成立**,同一段代码做的事变成"这个源坏了,所以整个 scope 停止服务":开路期间每一次调用都在几毫秒内失败,`MAX_ATTEMPTS` 一格用不上,一个网络包都没发出去。中转抖动几十秒就足以打断一条跑了几小时的长任务。
`wait` 档改变的**只是**"调用方当场失败还是排队等":等待期间照样一个请求都不发,熔断对配额和钱包的保护完整保留。代价是单次调用的最坏墙钟被拉长,上限为 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300 秒)。**`wait` 不豁免重试预算**——冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`;因此密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而非等满窗口的 `stalled`。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不要当场死"。多源部署保持 `fail_fast`:有源可换时,换源比等待快。
该键与 `{SCOPE}__QUOTA_FULL` 同形但**不可互相替代**:配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),所以两者分开配置。
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
@@ -178,7 +451,7 @@ graph LR
| `telemetry/` | SQLite / Postgres 遥测后端 |
| `structured/` | 结构化输出策略 |
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D14 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D15 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
## 可靠性证据
@@ -195,7 +468,7 @@ graph LR
## 开发
```bash
conda create -n PolyGateway python=3.11 && conda activate PolyGateway
conda create -n PolyGateway python=3.12 && conda activate PolyGateway
make install # editable 安装(dev + 全部 extras)
make test # pytest + 覆盖率(目标 ≥80%)
make lint # ruff + import-linter
+12 -3
View File
@@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.1.2"
version = "1.3.3"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
requires-python = ">=3.11"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27",
"pydantic>=2.8",
@@ -31,6 +34,11 @@ dev = [
"import-linter>=2.0",
]
[project.urls]
Homepage = "https://gitea.iomgaa.online/iomgaa/PolyGateway"
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/CHANGELOG.md"
Issues = "https://gitea.iomgaa.online/iomgaa/PolyGateway/issues"
[tool.setuptools.packages.find]
where = ["src"]
@@ -48,7 +56,7 @@ markers = [
]
[tool.ruff]
target-version = "py311"
target-version = "py312"
line-length = 100
[tool.ruff.lint]
@@ -73,6 +81,7 @@ layers = [
"polygateway.config",
"polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
+142 -8
View File
@@ -119,7 +119,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
---
## 3. 架构决策记录(D1D14,含讨论过程与备选方案)
## 3. 架构决策记录(D1D15,含讨论过程与备选方案)
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
@@ -219,6 +219,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 消灭 `"qwen" in provider``model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability``DEFAULT_CAPABILITIES``get_capability`/`register_capability``resolve_thinking``observe_thinking``reconcile_thinking``ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -248,6 +250,22 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。
### D15 库对下游数据库只做 SELECT/INSERT + 可选 CREATE;改结构与删数据归下游(2026-08-19,issue #13 立,issue #12 补删数据一面)
**决策**: 遥测表 `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类——catalog 探测(PG `to_regclass` + `pg_attribute`,SQLite `PRAGMA table_info`)、显式列名的 `INSERT`、以及表不存在时的 `CREATE TABLE IF NOT EXISTS`;**改结构(`ALTER`)与删数据(`UPDATE`/`DELETE`/`TRUNCATE`/`DROP`)一律归下游**。`ALTER` 保留唯一一个受控出口:`PGW_TELEMETRY_SCHEMA_MODE=auto` 时给已存在的旧表补列,而该档在 PG 侧**不是缺省**(缺省按后端派生: sqlite→auto、postgres→manual)。配套五条 Expand/Contract 承诺:新列只增不删不改名且追加在既有列之后、新列必可空或带非易失常量默认值、`INSERT` 永远显式列名、库从不 `SELECT *` 也从不读回该表数据、写入的冲突处理不绑定具体约束。
**背景与讨论**: 补列此前没有任何开关,库一升级、下次调用即在下游生产库上发 DDL。issue #13 的三条指控成立: ① 与最小权限原则冲突;② 多进程/多版本共存时谁先补列是竞态;③ DDL 不进任何迁移记录,DBA 事后无从审计。量级判据是 `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 `await`。调研的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。
两条边界是讨论出来的、不是照抄先例: **① 缺省按后端不对称**(D-a,人类拍板)——issue 引用的全部先例语境都是共享的生产 PG,而本库的 SQLite 侧是下游自己的本地文件(没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作),两侧统一 manual 会给零运维场景强加运维步骤;两侧有意不对称在本库已有先例(§7.8 的建表探测,issue #9)。**② manual 档不连 `CREATE TABLE` 一起停**——新建表没有既有数据与并发访问者,不存在锁队列与数据风险,停掉它会让"零配置起步"断掉(Celery 的先例同样是"自动建表 + 永不 ALTER")。**③ 关掉 `ALTER` 必须配套按现有列裁剪 `INSERT`**,否则旧表缺列时每行写入都被拒,是把自动补列换成静默全失能,比原问题更严重地违反「遥测必录」。
五条承诺本身是既有实现的**成文化**(零代码变更),但成文后才可被下游依赖——它同时是遥测保留期方案(issue #12)能成立的前提: 下游拿这份 schema 自己加 `PARTITION BY RANGE (created_at)` 建成分区表后,库的 `to_regclass` 探测、列探测与 `INSERT` 路由都照常工作。第五条(冲突处理不绑定约束)是审查带出的**新增**承诺,并伴随一处真实修复,见 §7.8。
**删数据这一半(2026-08-19,issue #12)**: D15 里 `DELETE`/`TRUNCATE`/`DROP` 归下游,不只是"库不去做",是库连**手段**都不该持有——保留期与访问控制因此以 README 的 DDL 模板加 `tools/telemetry_retention.py` 独立脚本交付,库本体不 import 该脚本,连接串上也不需要任何删权限。这是 (b) 保留期与 (c) 不可变性两条诉求的**权限张力**逼出来的唯一解: 模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`——分区在这里**不可替代**,不是性能偏好(`DROP PARTITION` 是 DDL,同样不触发行级的不可变性触发器,且 O(1)、不留膨胀)。脚本只是存量普通表的兜底: 默认 dry-run,探测到分区表即以退出码 3 让路。库本体在 #12 里唯一的代码面是**预防性**的正文截断(§7.8)——没写进去的数据不需要删,这也是三个子问题里唯一能靠库解决的那个。
**被否决的备选**: 两侧统一缺省 manual(语义最一致,但现有 SQLite 下游升级即需人工干预,而这些场景根本没有承接手工 SQL 的角色);保持 auto 缺省只加关闭档(默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足);Celery 式"自动建表但永不 ALTER、无开关"(SQLite 场景纯净损失,且真想要自动补列的下游没有出路);APScheduler 4.x 式"schema 不认识就拒绝启动"(与「遥测初始化失败必须静默降级」的库铁律正面冲突,不可选)。
**影响**: §7.8 补列一节按档位重写;新增配置键 `PGW_TELEMETRY_SCHEMA_MODE`(§9)与公共函数 `telemetry_schema_sql`;两个 recorder 新增 keyword-only 必填参数 `auto_migrate``GatewaySettings` 新增必填字段 `telemetry_auto_migrate`(缺省规则只写在 config 一处,不与类签名漂移);五条承诺进 README(随包分发)。issue #12 实现同一条边界的"删数据"一面: 新增可选键 `PGW_TELEMETRY_TEXT_CAP` 与遥测正文截断(§7.8、§9),保留期与访问控制走文档模板 + `tools/` 脚本,库的权限面不扩大。
---
## 4. 总体架构
@@ -309,6 +327,32 @@ flowchart TB
6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。
7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。
### 4.5 资源所有权纪律: 谁建的谁关,注入的一律不碰(2026-08-24,issue #15)
这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态——
| 形态 | 位置(修复前) | 性质 |
|---|---|---|
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 |
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 |
| 对照组: `RedisLimiter._owns_client` 的纪律**一直是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
纪律把已有的那个正确先例推广为全库唯一说法,分两层落地:
| 层 | 所有权归属 | 落法 |
|---|---|---|
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 |
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) |
三条实现细则各自都是"少写一条就等于纪律不成立":
1. **默认必须是"不拥有"**`__init__` 是全量注入路径,经它传入的一切组件一律 `_owns_* = False`,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。
2. **判定一律用 `is None` / `is not None`,不用 `or`**。工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好。
3. **三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次**。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;`GatewayClient` 此前把 limiter/breaker 交给 `RetryMW` 后自己不留引用,`aclose` 因此触达不到自建的 redis 客户端,泄漏就是这么来的。
公共 API 面零变化(`_owns_*` 是私有属性)。**对下游的可见后果**只有一条,且必须显式声明: `aclose()` 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。
---
## 5. 核心类型
@@ -328,7 +372,7 @@ flowchart TB
| `cache_hit` | bool | 是否缓存命中 |
| `call_id` | str | UUID,每次**尝试**独立 |
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens``model_reported`(2026-07-31,issue #3,见下)。
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens``model_reported`(2026-07-31,issue #3,见下)`thinking_observation`(2026-08-25,issue #16/#17,见下)、`applied_effort`(2026-09-05,issue #20,见 §7.5/§7.8 与下文推理档位段:本次**实际**跑在哪一档,`nearest` 映射后与请求档分叉,`None` = 调用方不表态或该路径无推理语义)
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
@@ -339,6 +383,22 @@ flowchart TB
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
| 值 | 含义 | 判据(按证据硬度排序) |
|---|---|---|
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
**声明 × 观测对账(同批;判据 2026-09-05 由布尔改档位,issue #20)**: `reconcile_thinking` 把本次**实际发出去的档位**(`effort: Effort | None`,即 `TransportResult.applied_effort`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。判据必须写成 `effort is Effort.NONE` 的**身份比较**才落「要求关闭」一支,其余任何档(含 `auto`)落「要求开启」一支——`Effort.NONE` 的取值是非空串 `"none"`,任何靠真值性的写法(`if not effort`)恒为假,会把每个强度档送进关闭分支、告警方向整个颠倒。`none × unknown``None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, 生效档位)` 集合(第三段 2026-09-05 由 `enable_thinking` 改为**实际发出的档**: 同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音;而档位根本不经过 `enable_thinking` 那个字段,不改就是同一模型的所有档共用一个键),与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
@@ -363,6 +423,16 @@ flowchart TB
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW``{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None``meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
**校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}``pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
**两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。
**维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。
**库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。
---
## 6. 错误模型
@@ -396,6 +466,10 @@ flowchart TB
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
**400 在中转拓扑下的语义提醒**(同上): 第三方 API 中转服务自身抖动时也会回 400,从状态码上与供应商的"输入非法"无法区分(下游实测: 同一份字节重发 15 次全成功,失败那次 `prompt_tokens=0`、耗时远低于任何成功调用,即请求在推理开始前被挡)。本表**不改** 400 → `RequestRejectedError` 的映射——直连供应商时重试只会白烧配额,且改默认语义等于让所有直连用户为一种部署形态买单;库改为把判据(`body_text`)交给下游自行区分。
### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer)
由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239`
@@ -442,14 +516,22 @@ flowchart TB
**M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。
**熔断拒绝补齐等待档(2026-08-19,issue #14,设计 `designs/2026-08-19-issue14-admission-wait-policy-design.md`;人类确认缺省与实施边界)**: 准入侧此前有一格是空的——限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 wait),熔断门拒时**只有 fail-fast 一档且不可配**。两者在准入语义上同构(都不发请求、都带 `retry_after` 提示),处置却分叉。补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 **fail_fast**,不跟随 quota_full——把最坏墙钟从毫秒抬到 stall 窗口是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游)。`wait` 档下熔断的保护作用完整保留(等待期一个请求都不发),改变的只是调用方当场死还是排队等。**这一格的缺失与源数量无关**: 多源全部同时开路(共同上游挂掉、全网抖动)行为一模一样,单源只是把"全部开路"的概率从罕见变成必然;故实现上**严禁按池大小分叉**(`if len(sources) == 1` 会让行为随配置突变且无法组合测试)。等待时长按 `retry_after_s` 睡到冷却截止(而非 `poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次往返 × 每个在途调用),抖动**上**加不缩放(对确定的截止时刻提前醒必然白醒),并夹到剩余 stall 预算,故单次调用最坏墙钟 = `stall_window_s` + 一个 poll 间隔,不随 `max_cooldown_s` 漂移。控制流必须**按拒绝原因分派**而非串行: 串行写法下 `circuit_open=wait` 不抛之后会掉进配额分支,`quota_full=fail_fast` 的调用方会收到 `reason=quota_exhausted` 而配额其实是满的。
**`retry_after_s` 的契约定死(同批,issue #14)**: 语义 = "距离**确定**可再试的时刻还有多久"。CLOSED/准入允许 → `0.0`(现在就能试);OPEN → 剩余冷却(确定时刻);**HALF_OPEN → `0.0`**——探针随时可能出结果,不存在确定时刻,而 `0 = 可立即重试` 本就是库既有约定。此前 HALF_OPEN 返回**探针租约剩余**,那是死锁保护参数(派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`),与"源多久能恢复"无因果关系: 现场 `TIMEOUT_S=300` 时它是 600s 而冷却只有 60s。**更重的后果不在对外报数而在库内**: 该值被喂进源冷却备忘(`SourceCooldownMemo.set_until` 取更晚者、不可回退),于是探针成功、门已恢复 CLOSED 之后,本进程仍跳过该源整整一个租约——单源下每次调用照旧判死,多源下则是"池子里少一个源"且被其他源接住流量所掩盖(issue 提交方未发现这一条)。修正后备忘写入的是已过期时刻,自动回归"只记 OPEN 的确定冷却期"。契约在**六个出口**上统一(memory 三处 + redis 六个 Lua 返回格),其中后四处是**既有的双后端分叉**(redis 在授予探针时返回 probe TTL、在 fencing 未命中时返回租约剩余,而 memory 一直是 0),由契约测试盲区掩护至今——旧用例只钉"第二个进入者被拒",从没钉它拿到什么数。
**准入逻辑三处收敛(同批)**: `_pick_runnable`/`_on_no_runnable` 此前在 `middleware/retry.py``embedding.py``ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义一直在演进(issue #8 的 stall 口径、M2.5 的 pacer、本次的等待档),每次都要三处同步。收敛为 `middleware/admission.py::SourceAdmission`,差异用注入表达而非分支: 调用内降权传空 `attempt_fails` 时恒等、AIMD pacer 为 `None` 时跳过。`QuotaGate`/`BreakerGate`/`AdaptivePacer` 由三条循环持有并与 admission **共享同一实例**(三处 `_attempt` 仍要用它们做记账写回与 `pacer.leave()`;pacer 有在途计数,分裂成两个计数器会让 admit/enter 与 leave 记到不同账上),`SourceCooldownMemo` 归 admission 独占。
### 7.5 响应缓存
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
- `reasoning_effort`(2026-09-05,issue #20): **请求级**档位,仅 `is not None` 时参与(判据不能用真值性——`Effort.NONE` 是「明确要求不推理」,与 `None`「不表态」拿到的是两种响应,合并即毒化)。它不能靠 `model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,同一个 client 上跑 low 与 max 在它眼里毫无分别,不进 key 就是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。**源级** `reasoning_effort` 则与 `enable_thinking` 同规则并入 `_fingerprint_mark`(仅表态时追加,故全源不表态时字面量逐字不变、存量缓存不冷启动;两者取值域不相交,`"none"`/`"low"`… vs `true`/`false`,追进同一个列表不会摘要成同一身份)。
- **key 记的是请求档,不是 `nearest` 映射后的生效档**: `CacheMW` 在洋葱里比 transport 更外一层,查缓存时 `resolve_thinking` 尚未执行,生效档根本拿不到。副作用是被映射到同一档的两个请求各占一个缓存槽(存两份相同响应,浪费但不毒化)。**由此的已知边界**: 能力表更新导致映射结果变化时(如某模型新增 `minimal` 档),请求档算出的 key 不变而实际发出的字节变了,会命中按旧映射存下的响应——能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace``cache_salt`
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
@@ -459,7 +541,7 @@ flowchart TB
### 7.7 多源与选源
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking/`reasoning_effort``effort_fallback`(2026-09-05 issue #20: 前者是本源默认推理档位,`None` = 不表态、`Effort.NONE` = 要求不推理,构造期与 `enable_thinking` 语义矛盾即 `ValueError`;后者取 `error`(缺省)或 `nearest`,决定请求档打空时报错还是映射到最近档)/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
@@ -471,16 +553,63 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。
**必录字段**(继承三项目 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。
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
**`reasoning_effort` 列(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL``emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档。
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter``_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457``if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=<PGW_TELEMETRY_PG_POOL_MAX>, timeout=<预算>, command_timeout=<预算>)`,三条随之确立:
| 语义 | 内容 |
|---|---|
| 建池零成本 | `min_size=0``_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 |
| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) |
| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 |
两处实现纪律,都是"看起来完成了、其实资源还挂着"的形态,必须写下来否则会被改回去: ① **不得用 `async with pool.acquire(...)`**——`Pool.release()``await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`),外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行的那个 shielded release **会正常等到完成**,业务路径真实上界变成 ≈ 2 × 预算;故改为显式 `acquire(timeout=<完整写入预算>)` + `finally: release(con, timeout=1s)`(内层传完整预算而非剩余量: 真正的上界是外层那一层 `asyncio.timeout`),释放超时即 `con.terminate()`,承诺精确化为"主写入尝试 ≤ 预算,释放路径独立有界"。② **`aclose()` 必须有界且终局**: `Pool.close()``await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时无限等、60 秒只发一条 warning(`pool.py:939-948, 961-972`),故走 `asyncio.wait_for` + 超时 `terminate()`;同时置 `_closed`,此后写入短路且**不复活**——原实现关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入方以为自己管着全部连接、实际早已不是(issue #15 实施期发现,是下面所有权根因的又一处表现)。
**遥测失败的三分判据(2026-08-24,issue #15)**: 判死判据此前挂在"**哪一步**失败"(`_open_pool` 失败即永久判死),而那一步里同时藏着两类性质完全不同的失败——DSN 非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。判据改挂"失败是**什么性质**",两句话说完:
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
| 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 |
|---|---|---|
| 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
| 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call``except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 |
| 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 |
四点必须一起记住,否则后来人会把判据改回去: ① **致命档窄到只剩 DSN 一类是有意的**——认证失败、库不存在、表建不出来一律归环境级,因为 DBA 改完密码/建完表就该自动恢复,而永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形;②**`42703` 是唯一具名例外**,按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了优先级更高的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,即"部分列写进去了"这件事本身有价值,不该被冷却掉;新增例外必须同款论证。③ **认不出的失败一律归最轻档(行级)**,这个保守缺省在建池路径上是安全的,理由是 `min_size=0` 让建池不触库(实测 0.000s),"下次调用重试建池"本身**零成本**——原实现注释担心的"每次重试内联吞一次 connect 超时"在新语义下不再成立;④ **表里那条 `TimeoutError` 规则只在准备期路径可达,写入期不可达**(2026-08-24 合并前审查发现,**本轮只记录不改行为**): `record_llm_call``except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的任何超时都在那里被按行级丢弃,不会走到分类函数。真实后果是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用内联付满一个写入预算(缺省 5s)、丢一行、`degraded` 保持 False、**不进 60s 冷却**——即"冷却把最坏成本压成每 60s 一次、上界一个预算"这句承诺只在准备期路径上成立。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行走行级、不置 degraded"本就是明确记下的有意取舍(见下一段中"`degraded``dropped_rows` 覆盖的不是同一件事"那一条)。是否给"连续超预算丢行"升档,留作后续议题。
**降级的可见性与可编程性(2026-08-24,issue #15)**: 铁律里"遥测后端挂 → 静默降级"的"静默"指的是**不向调用方冒泡**,不是"没有日志、没有状态"。此前它被实现成了后者——全程只有一条 warning,长跑进程里等同于消失(issue 是人工比对"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的,期间 19 次调用一行未落);SQLite 侧更糟,初始化失败后写入直接 `return`,连 warning 都没有。"遥测必录"铁律的实质要求是: **库做不到必录时,必须持续、可编程地让下游知道**。落法是 `telemetry/status.py``TelemetryStatusTracker`——两个 recorder 共用、不含任何后端知识(只接受"降级了/恢复了/丢了一行"三个事实),进入与恢复各一条日志(**进入那条的级别由 `fatal` 决定,且只在 tracker 这一处决定**: 致命档 error——人配错了、本进程内不会自愈,其余 warning——外部状态、会自愈;recorder 侧不得再复制一条,否则同一事实两条日志、级别两个源头),降级期间按行数(100 行)与时间(300s)双阈值节流复述,`snapshot()` 给只读 `TelemetryStatus`(`degraded`/`fatal`/`reason`/`degraded_for_s`/`dropped_rows`/`retry_after_s`),经三个 client 的 `telemetry_status` 属性出口。三条设计约束:
- **不叫 `health`**: 该词在 `ports.py` 已被 `OcrTransport.check_health`(源探活)与 `SourceSelector.health(source_name) -> float`(成功率 EWMA)占用两次,库内 `health` 一律指"源的健康度";这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分(P2)。
- **不并入 `TelemetryRecorder` 主 Protocol**,新起**独立**端口 `TelemetryStatusProvider`: 前者是 `@runtime_checkable`,而 runtime 检查按属性存在性做——加一个成员会让所有只实现 `record_llm_call` 的对象**当场不再是** `TelemetryRecorder`,库内与下游的同款 `isinstance` 断言升级即断。client 侧取值经**一处** `isinstance` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
- **`TelemetryStatus` 进顶层 `__all__`**(与 `SourceStats` 不同): 后者是端口内部快照、下游不消费,而本类型是 `client.telemetry_status` 的返回类型,下游要拿它做类型标注与对账——"顶层导出即公共 API 面"的约定要求它出现在那里。端口 `TelemetryStatusProvider` 则不导出(库外无实现者,导出即多一份永久承诺)。
- **`degraded``dropped_rows` 覆盖的不是同一件事,下游对账必须两个都看**: `degraded` 只在**环境级/致命级**失败(服务端真的说了"不可用",如 53300)时置位;而写入因**本地池饱和**超出写入预算被丢时走的是行级丢弃——`degraded` 保持 False,只有 `dropped_rows` 增长。这是有意的(池满是本进程并发过高,不是后端挂了,冷却 60s 只会白丢更多行),但只按 `degraded` 配告警的下游会**完全看不见**这一类丢行,而它恰恰是 `pool_max` 配小了的唯一信号。
- **SQLite 侧只做可见性**,不做 lazy 化与冷却重连: 它的失败模式(本地目录不可写、文件损坏)在装配期就暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确。这个不对称是已知且有理由的;tracker 与快照两侧共用,将来要对称时接口已就位。
**资源所有权在遥测侧的落点**: 通用纪律见 §4.5。对遥测的直接后果是 §7.7 R5 那条"共享必须显式注入"第一次真正可用——`PostgresRecorder(dsn, pool=<外部池>)` 与"多个 client 注入同一个 recorder"都不再被第一个 `aclose()` 弄死,issue #15 提的"共享池"方向由此以显式注入形态自然成立,不需要任何隐式全局注册表(那会违反"纯 asyncio 中立: 无全局状态、无模块级单例")。
### 7.9 结构化输出阶梯(D14)
| 级 | 内容 | 成本 |
@@ -519,7 +648,8 @@ src/polygateway/
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.py
├── providers.py # D11 provider 注册表
├── providers.py # D11 provider 注册表(只回答 provider 是什么)
├── thinking.py # 推理这件事的全部决策: 能力表 + 请求侧注入 + 响应侧裁定 + 对账
├── sources.py # SourceConfig + 选源策略
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
├── telemetry/ # sqlite.py / postgres.py / pricing.py
@@ -527,7 +657,7 @@ src/polygateway/
└── streaming.py # 三层活性看门狗(纯函数)
```
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured``providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
---
@@ -540,6 +670,10 @@ src/polygateway/
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis``PGW_TELEMETRY_BACKEND=sqlite|postgres``PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
- **`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(2026-08-24,issue #15)**: 两个可选键,**env 装配路缺省 4 与 5.0**。库必须对"自己该占多少资源"有一个可陈述的表态(不表态就等于继承第三方默认值,那正是 issue 的病根,见 §7.8),但**表态的落点是 `_load_pool_max`/`_load_write_timeout` 这条 env 装配路,不是字段默认值**: `GatewaySettings.telemetry_pg_pool_max` / `telemetry_pg_write_timeout_s` 与相邻三个遥测键**一样是无默认值的必填字段**,直接构造 `GatewaySettings` 的调用点需补两个参数(dataclass 语义上也只能如此——这两个字段后面跟着四个无默认值字段,就地加默认值即 `TypeError: non-default argument follows default argument`)。缺省写在 config 一处,`PostgresRecorder``pool_max`/`write_timeout_s` 是 keyword-only **必填**参数(与 `auto_migrate` 同一纪律: 缺省规则不与类签名漂移)。值域校验(`pool_max >= 1`、`write_timeout_s > 0`)落 `GatewaySettings._validate_telemetry`,与 `telemetry_text_cap` 同一先例覆盖**三条装配路**(直接构造 / `dataclasses.replace` / env),报错文本同时点字段名与 env 键名。两键都带 `PG` 前缀与 `PGW_TELEMETRY_PG_DSN` 对齐: SQLite 侧的等价物(`busy_timeout=5000`)本次不动,这个不对称是已知且有理由的(§7.8 末)。**冷却期 60s 有意不给键**——无部署差异理由(P1 YAGNI)。`pool_max` 的调参口径必须按实测折算而非按 `pool_max / RTT` 估算: 跨内网 RTT ≈ 123ms 的实验室 PG 上 `pool_max=4` 实测约 **15.6 行/秒**(50 行并发批 3.2s),一次 `INSERT` 的实际往返比一次 `SELECT 1` 重一倍。
---
@@ -612,7 +746,7 @@ src/polygateway/
| # | 问题 | 建议 |
|---|---|---|
| Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 |
| Q2 | Python 最低版本 | 3.11(覆盖三项目: 3.11×2 + 3.13×1) |
| Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") |
| Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 |
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
| Q4 | conda 环境名 | `PolyGateway` |
+1 -1
View File
@@ -28,7 +28,7 @@
|---|---|---|
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(thinking 注入/思考流字段声明) |
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
| 3 | `transports/openai_compat.py`(SSE 解析、非流式快路径、错误翻译 §6.2) | 依赖 1/2a/2b;错误翻译是中间件的语义地基 |
| 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
@@ -0,0 +1,238 @@
# HTTP 错误响应体留存设计(Issue #10)
- **日期**: 2026-08-16
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
- **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺)
- **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计)
## 1. 目标与非目标
| | 内容 |
|---|---|
| **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 |
| **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" |
| **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 |
| **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 |
| **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error``operation` 硬编码缺陷(见 §5.4) |
### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实)
| Issue 原文 | 实际情况 |
|---|---|
| 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558``middleware/telemetry.py:89``telemetry/sqlite.py:43``error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) |
| 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 |
## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message
`_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。
三条留存通道同时为空,是"永久查不到"的完整解释:
| 通道 | 现状 | 本设计后 |
|---|---|---|
| 日志 | 模块无 logger | 仍无(§6.2:不加日志) |
| 异常字段 | 无承载处 | `body_text`(§3.1) |
| 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) |
## 3. 选定方案
### 3.1 内核:`PolyGatewayError` 基类新增 `body_text`
```python
class PolyGatewayError(Exception):
def __init__(self, message, *, source_name=None, status_code=None,
operation=None, body_text: str = "") -> None:
```
**加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。
与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用:
| 字段 | 语义 | 来源 |
|---|---|---|
| `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 |
| `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 |
`GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。
### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行)
两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数:
| 函数 | 职责 | 关键防御 |
|---|---|---|
| `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` |
| `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** |
- **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。
- **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。
- **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。
放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。
### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要
`_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短:
```
summary = summarize_body(body_text) # 全函数只算一次
ctx = {..., "body_text": summary} # 字段
429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列
其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx)
```
message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。
**429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。
`monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。
### 3.4 常量取值
**机械规则(实现与测试逐字照此)**:
```
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
折叠空白后 len ≤ 2048 → 原样返回
否则 → s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:]
```
> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。
**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。
### 3.4.1 取值依据:同场景开源实践
| 项目 | 场景 | 上限 | 保留策略 |
|---|---|---|---|
| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 |
| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — |
| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 |
| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 |
| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** |
**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。
遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`TEXT` 列可忽略。
message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
### 3.5 改动清单
| 文件 | 改动 |
|---|---|
| `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring |
| `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` |
| `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429``ctx` |
| `transports/monkey_ocr.py` | `_classify_status` 带摘要 |
| `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) |
| `README.md:34` | 安装 pin `==1.1.*``>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) |
三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。
## 4. 非功能维度
### 4.1 并发与取消
新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await``response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。
### 4.2 降级方向
响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。
### 4.3 幂等与重复
纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。
### 4.4 持久化与原子性
不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。
### 4.5 安全与体积
- **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。
- **本设计不放大既有的读取风险**:`_complete_stream:416``aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。
## 5. 错误处理、语义与边界
### 5.1 错误分类
不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。
### 5.2 400 语义:不改行为,补文档
Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。
**仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。
**但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。
### 5.3 兼容性
`LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。
版本 **1.2.0**(公共类型新增字段属 minor;2026-08-16 人类定夺)。
**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。
### 5.4 有意不夹带的两项(建议单开 issue)
| 项 | 说明 |
|---|---|
| `_status_to_error``operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 |
| `_complete_stream:416``aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 |
两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。
## 6. 被否决的路线
### 6.1 给遥测端口加一列(22 → 23 字段)
最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。
### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二)
不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。
### 6.3 截断放在异常构造器内
构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。
### 6.4 错误分类映射可插拔(provider profile 注入 classifier)
Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。
## 7. 测试策略(先失败后通过)
**验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。
| # | 用例 | 覆盖 |
|---|---|---|
| 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 |
| 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 |
| 3 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 |
| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 |
| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 |
| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 |
| 4 | 多行缩进 JSON → 折叠为单行 | G3 |
| 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 |
| 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 |
| 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 |
| 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 |
| 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 |
`tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。
## 8. 人类定夺记录(2026-08-16)
| 议题 | 定夺 |
|---|---|
| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) |
| 429 是否设例外 | **不设**,一律拼摘要(§3.3) |
| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) |
@@ -0,0 +1,230 @@
# 调用方自定义维度设计(issue #11)
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
---
## 1. 需求与边界
### 1.1 issue 原始诉求
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()``embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。
**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
### 1.2 本次放大的范围(2026-08-17 人类决策)
issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:
- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
- 保留期与访问控制不在本次范围(issue 第 4 条)。**已另开 issue #12**(2026-08-17)。
**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit``ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。
### 1.3 明确不做
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。
---
## 2. 关键既有事实(设计必须服从的约束)
| # | 事实 | 出处 | 对本设计的约束 |
|---|---|---|---|
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |
---
## 3. 备选方案对比
### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)
`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。
**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。
### 方案 B: 纯 `meta` JSON,不提任何列
最小改动、最通用。**否决**,两条独立的实证:
**RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
**planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)
最通用,下游不必等库发版。**否决**,理由分三层:
**业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
**本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。
**与端口契约冲突**`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。
**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。
### 推荐
**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
---
## 4. 设计细节
### 4.1 公共 API
四个公共方法对称新增两个 keyword-only 参数(F2/F3):
`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)``parse_layout(image, *, ..., 同两参数)`
**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。
**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)
| 项 | 规则 | 依据 |
|---|---|---|
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
| `meta` value: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
**非有限 float 必须在入口拒绝(Codex 审查发现)**`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()``embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223``validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。
### 4.3 内部流转: 三条独立链路
`ChatRequest``tenant_id: str | None = None``meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。
`TelemetryEmitter._record` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**`EmbeddingClient` 不经过 chat 洋葱: `embed()``_embed_batch()``_attempt()``_emit()`,而 `_emit()``embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。
**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()``_call()``_attempt()``_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。
**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
### 4.4 存储层
两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):
- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`
**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。
**库不自动建索引**`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
### 4.5 RLS: 库止步于列 + 模板
**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:
**default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。
**库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。
**权限不对等**`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
**SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING``WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。
---
## 5. 旧版行为审计
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
| 契约 | 处置 |
|---|---|
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
---
## 6. 非功能维度
**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
---
## 7. 错误分类与测试策略
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
**测试策略**(合并前需先失败后通过的证据):
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
**三条链路各测一遍**——`chat()``embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text``parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。
非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})``ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。
**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
---
## 8. 开放问题(留待人类审批时确认)
1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
2. `tenant_id` 长度上限 128 同为推断值。
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次。**已另开 issue #13**(2026-08-17)。
@@ -0,0 +1,145 @@
# issue #12 设计: 遥测表的正文体量、保留期与访问控制
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例)
> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md)
## 1. 问题
`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。
## 2. 已定决策(人类,2026-08-19)
| # | 决策 | 选择 |
|---|---|---|
| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) |
| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 |
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
## 3. 三个子问题的边界
| 子问题 | 库能做什么 | 性质 |
|---|---|---|
| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 |
| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP |
| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 |
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
## 4. 备选方案对比
| 方案 | 内容 | 权衡 | 结论 |
|---|---|---|---|
| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
## 5. 设计: (a) 正文截断
### 5.1 配置与装配
| 层 | 形态 |
|---|---|
| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) |
| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0``ValueError` |
| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) |
### 5.2 作用面与切法
截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"``text` 字段)、`response``thinking`
**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。
**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73``ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `<ocr:kind image_bytes=N>` 占位、`response``_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
### 5.3 红线
**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
## 6. 设计: (b) 保留期
**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。
**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。
### 6.2 模板与工具
分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13`telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则):
| 项 | 设计 |
|---|---|
| 参数 | `--backend sqlite\|postgres``--path/--dsn``--older-than-days N``--apply`(**默认 dry-run**)、`--batch-size``--vacuum`(仅 SQLite,显式) |
| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 |
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE |
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) |
| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
## 7. 设计: (c) 访问控制与不可变性(纯文档)
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
| 内容 | 要点 |
|---|---|
| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) |
| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 |
| 分区 | 与 §6 的 retention 模板同一段落 |
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
## 8. 非功能维度
| 维度 | 结论 |
|---|---|
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 |
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 |
| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 |
| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
## 9. 错误处理与测试策略
| 层 | 用例 |
|---|---|
| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 |
| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" |
| unit | config: 未设 → `None`;`<= 0``ValueError`;合法值透传到 emitter |
| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 |
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 |
| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。
## 10. 兼容性、文档与发布
**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。
## 11. 开放问题
1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。
2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。
@@ -0,0 +1,146 @@
# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来)
> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md)
## 1. 问题
两个遥测后端在构造期(SQLite)/首次写入前(PG)会对下游数据库发 DDL: 表不存在则 `CREATE TABLE`,表存在但缺列则逐列 `ALTER TABLE ... ADD COLUMN`。**补列没有任何开关**,库升级后首次调用即自动执行,而 issue #11 刚给这张表加了两列,这条路径的使用频率正在上升。
issue #13 的三条指控成立: ① 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突; ② 多进程/多版本共存时谁先补列是竞态; ③ DDL 不进任何迁移记录,下游 DBA 事后无从审计表何时被谁改过。调研的 11 个同类先例(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中,**没有一个支持"库在下游库里自动 ALTER 出列"作为默认行为**。
### 1.1 issue 未区分、但决定方案形状的两点
**① SQLite 与 Postgres 的风险完全不对称。** issue 引用的全部先例(Hangfire 的锁队列雪崩、Prefect 的多实例竞态、Alembic 的 DBA 审计链)语境都是**共享的生产 PG**: `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。本库的 SQLite 侧则是下游自己的本地文件(VT / CHSAnalyzer / dissect 的 `runs/*.db` 全是这个形态): 没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作。让 SQLite 也要求"升级后手工跑一条 SQL",是给零运维场景强加运维步骤。两侧有意不对称在本库已有先例——`sqlite.py` 文件头写着"别为了代码对称把建表探测加回来"(issue #9)。
**② 关掉 ALTER 必须配套"按现有列裁剪 INSERT",否则是把自动补列换成静默全失能。** 今天 `_INSERT` 是 24 列的固定语句。旧表缺 `tenant_id` 时若不 ALTER,INSERT 会因未知列**全部失败** → 逐行 warning → 遥测彻底丢失。这比自动 ALTER 更严重地违反"遥测必录"。故降级写入不是可选增强,是本变更成立的前提。
## 2. 已定决策(人类,2026-08-19)
| # | 决策 | 选择 |
|---|---|---|
| D-a | 默认档 | **不对称**: PG 默认 manual(不 ALTER),SQLite 默认 auto(保持自动);同一配置项两侧均可覆盖 |
| D-b | SQL 投放渠道 | warning 打印完整语句 **+** 新增公共函数供下游主动索取 |
| D-c | 缺省规则落点 | **config 层派生**,recorder 的开关参数为 keyword-only **必填** |
| D-d | 交付节奏 | 独立分支实现,与 issue #12 合并发 **1.2.3** |
## 3. 备选方案对比
| 方案 | 内容 | 权衡 | 结论 |
|---|---|---|---|
| **A(采纳)** | 按后端不对称默认 + 三态配置 + 裁剪写入 + schema SQL 公共函数 | PG 侧满足 issue 全部诉求;SQLite 侧零运维负担不变;代价是同一配置键在两后端缺省值不同,须文档讲清 | ✅ |
| B | 两侧统一默认 manual | 语义最一致、最贴 issue 原文 | ❌ 现有 SQLite 下游(VT/CHS/dissect)升级即需人工干预,否则新维度静默缺失,而这些场景根本没有承接手工 SQL 的角色 |
| C | 保持 auto 默认,只加关闭档 | 非破坏性 | ❌ 默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足,只是提供了绕法 |
| D | Celery 式: 自动建表但**永不** ALTER,无开关 | 最简、无配置面 | ❌ SQLite 场景纯净损失;且下游若确实想要自动补列,库不给任何出路 |
| E | APScheduler 4.x 式: schema 不认识就 `RuntimeError` 拒绝启动 | 最安全的一致性保证 | ❌ 与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选 |
## 4. 设计
### 4.1 配置与装配
新增环境键 `PGW_TELEMETRY_SCHEMA_MODE`,值域 `auto | manual`,**三态**: 未设 = 按后端派生,显式设置 = 两侧都可覆盖。
| 层 | 形态 | 理由 |
|---|---|---|
| 环境 | `PGW_TELEMETRY_SCHEMA_MODE`(可选键),经既有 `_load_choice` 校验值域 | 与 `PGW_LIMITER_BACKEND` 等同族 |
| `GatewaySettings` | 新增字段 `telemetry_auto_migrate: bool`,**无默认值**(与既有全部字段一致) | settings 承载的是装配事实而非环境文本;派生只发生一次 |
| recorder | `SQLiteRecorder(db_path, *, auto_migrate: bool)``PostgresRecorder(dsn, *, pool=None, auto_migrate: bool)`,keyword-only **必填** | D-c: 关键行为参数不给默认值(P4);缺省规则只写在 config 一处,不会与类签名漂移 |
`telemetry_backend=none` 时无 recorder 消费该字段,派生为 `False`
### 4.2 行为矩阵
| 场景 | auto(今天的行为) | manual(新增) |
|---|---|---|
| 表不存在 | 建表 | **仍然建表** |
| 表存在、列齐 | 不发任何 DDL | 不发任何 DDL |
| 表存在、缺列 | 逐列 ALTER;失败只 warning,不判死 | **不发 DDL**;warning 逐列点名 + 打印可执行 SQL(仅一次);按现有列裁剪 INSERT 继续写入 |
| 列探测失败 | warning,沿用全量 24 列 | warning,沿用全量 24 列 |
**manual 档为什么不连 `CREATE TABLE` 一起停**: issue 把建表列为现状描述而非指控(它已在 #3/#9 收口为"先探测后建")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。Celery 的先例同样是"自动建表 + 永不 ALTER"。
### 4.3 裁剪写入
`effective_columns = [c for c in COLUMNS if c in existing]`(保序),据此实例级构造 INSERT 语句,`record_llm_call``self._columns` 取值。SQLite 在 `__init__` 末尾定型,PG 在 `_prepare_schema` 成功后与 `_schema_ready` **一起**赋值(两者必须同时生效,否则会出现"已就绪但语句还是旧的"的窗口)。
缺列 warning 必须**逐列点名**并写明后果("以下维度不会被记录: tenant_id, meta"),不能只说"缺列"——静默丢维度的后果是多租户账目全归空串且无任何报错。warning 只在准备期发一次,不逐行。
`call_id` 若不在现有列内,说明该表不是本库的 `llm_calls`(下游魔改或撞名),warning 升级措辞并照常尝试写入(由数据库自己拒绝),库不做二次判定。
### 4.4 新公共函数(D-b)
```python
polygateway.telemetry_schema_sql(backend: str) -> str
```
返回可直接粘进迁移文件的完整脚本: 注释头 + `CREATE TABLE IF NOT EXISTS`(全量列) + 分隔注释 + 各补列语句(PG 用 `ADD COLUMN IF NOT EXISTS`;SQLite 无该语法,以注释标明"仅当列不存在时执行")。非法 `backend``ValueError`(公共入口显式校验,先例同 issue #11 的维度校验)。
**这不是锦上添花而是正确性要求**: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 `_DDL` / `_BACKFILL` / `_COLUMNS``sqlite.py``postgres.py` 各存一份,公共函数若再写一份,三份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。故新增 `telemetry/schema.py` 收敛为单一事实源,两个 recorder 与公共函数共用;顶层 `__init__` re-export 进 `__all__`。依赖方向不变(schema.py 在 telemetry 层内部,不 import 任何其他层),import-linter 契约无需改动。
### 4.5 Expand/Contract 成文化(零代码)
库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)、写入的冲突处理不绑定具体约束**。最后一条是 Codex 审查带出的**新增承诺**,见 §4.6。
它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测、列探测与 INSERT 路由都照常工作。
### 4.6 冲突目标改为无绑定(Codex 审查发现,阻断级)
PG 侧今天的写入是 `ON CONFLICT (call_id) DO NOTHING`,它要求一个恰好匹配 `(call_id)` 的唯一约束。而 PostgreSQL 要求分区表的唯一约束**必须包含分区键**——issue #12 的按 `created_at` 分区方案会把主键逼成 `(call_id, created_at)`,届时该语句**直接报错**,遥测在分区部署下全线写不进去。
改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 两种表形态都合法,普通表上与今天逐字等价(表上只有主键这一个唯一约束),SQLite 侧的 `INSERT OR IGNORE` 本就无目标、无需改动。
改动归属本 issue 而非 #12: 本 issue 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。分区部署下幂等语义的差异(缓存命中行复用历史 `call_id`)由 #12 的文档承接。
## 5. 旧版行为审计
| 既有行为 | 处置 |
|---|---|
| SQLite 构造期 `PRAGMA table_info` 探测 | 保留 |
| SQLite 逐列独立 try、`duplicate column` 视为成功(多进程共库竞态) | 保留(auto 档) |
| SQLite 补列失败只 warning、绝不清空 `_conn` | 保留 |
| SQLite 不做建表前探测(issue #9 的有意不对称) | 保留 |
| PG `to_regclass` 建表前探测(权限检查早于 IF NOT EXISTS) | 保留 |
| PG `pg_attribute` 列探测(避开 `ADD COLUMN IF NOT EXISTS` 的排他锁) | 保留 |
| PG 补列失败不置 `_failed`、探测失败只跳过本次下次重试 | 保留 |
| 24 列模块级固定 INSERT 常量 | **替换**为按探测结果裁剪的实例语句 |
| `_DDL`/`_BACKFILL`/`_COLUMNS` 两文件各一份 | **替换**为 `telemetry/schema.py` 单一事实源 |
| 补列无开关、库升级即自动执行 | **替换**为 `schema_mode` 三态配置 |
| PG `ON CONFLICT (call_id) DO NOTHING` | **替换**为无冲突目标的 `ON CONFLICT DO NOTHING`(§4.6);普通表上语义逐字等价 |
| SQLite `INSERT OR IGNORE` | 保留(本就无冲突目标) |
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
无有意放弃项。
## 6. 非功能维度
| 维度 | 结论 |
|---|---|
| 并发与取消 | DDL 与探测仍只发生在构造期(SQLite)/首次准备期(PG,由既有 `_init_lock` 串行);manual 档不发 DDL,多进程竞态面积**缩小**;裁剪是纯计算,不新增 await 点;PG 既有 `except asyncio.CancelledError: raise` 全部保留 |
| 降级方向 | 遥测属静默降级档: 缺列 → 降级写入 + warning,**绝不判死、绝不报错**;与"限流/熔断后端不可用须报错"的方向差异不变 |
| 幂等与重复 | 探测与裁剪是纯读,重复执行安全;auto 档 ALTER 经探测 + duplicate 容错幂等;`ON CONFLICT (call_id) DO NOTHING` / `INSERT OR IGNORE` 不受影响 |
| 持久化与原子性 | 无跨行事务;单条 INSERT 原子;裁剪不触及主键 `call_id`,幂等键语义不变;部分写入不可能发生 |
## 7. 错误处理与测试策略
遥测路径的一切失败仍不落四分类、不冒泡;`telemetry_schema_sql` 的非法参数是公共入口校验,抛裸 `ValueError`
| 层 | 用例 |
|---|---|
| unit(真实临时 SQLite) | manual + 22 列旧表 → `PRAGMA` 列数不变(证明未 ALTER)、INSERT 成功且能读回、warning 同时含缺列名与 ALTER 语句;auto + 22 列旧表 → 补列(现状回归) |
| unit | `telemetry_schema_sql``COLUMNS` 同源(输出含全部列名且顺序一致)、非法 backend 报 `ValueError` |
| unit | config 派生: 未设键 → sqlite `True` / postgres `False`;显式设置覆盖两侧;非法值报错;`backend=none``False` |
| integration(真实 PG) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 |
| integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning |
每条行为变更须有先失败后通过的证据(测试结果门)。
## 8. 兼容性、文档与发布
**破坏性**(CHANGELOG 须给"请先读这一条"待遇): ① PG 下游升级后不再自动补列,新列需手工执行(库会打印语句); ② 两个 recorder 新增 keyword-only 必填参数,直接构造的调用点需改(全库 35 处,除 `client.py` 的两处装配点外均在测试内); ③ `GatewaySettings` 新增必填字段,影响"构造函数全量注入"这条装配路。
文档同步: README(配置键、Expand/Contract 承诺、schema SQL 用法)、`.env.example`、ARCHITECTURE §7.8、Wiki `参考-配置键` / `参考-公共API` / `指南-遥测与成本`
## 9. 开放问题
1. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。
@@ -0,0 +1,227 @@
# 熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
- **issue**: #14(dissect,单源第三方中转部署)
- **核查基准**: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后**全部仍然成立**(`backends/memory/breaker.py` md5 `630ed36ddeb87e08a9bac58260056046`,1.0.6→1.2.3 逐字节未变)
- **状态**: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施
## 1. 问题的真实形状
issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——**单源不是病因,是让病灶 100% 复现的放大器**。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。
### 1.1 缺陷一: 准入策略矩阵缺了一格
`_pick_runnable` 有四种"拒绝",库对它们的处置并不对称:
| 拒绝原因 | 计入 `gate_rejections` | 全被拒时的处置 | 可配? |
|---|---|---|---|
| `rate_limited`(permit 拿不到) | 否 | 走 `quota_full` 分支 | **是**(`wait`/`fail_fast`) |
| `adaptive_paced`(AIMD 超限) | 否 | 走 `quota_full` 分支 | **是**(同上) |
| `circuit_open`(熔断门拒) | 是 | 当场抛 `CircuitOpenError` | **否** |
| `cooldown`(源冷却备忘) | 是 | 同上 | **否** |
限流闸满时库不判死、允许排队(`quota_full=wait`,缺省);熔断门拒时库**只有 fail-fast 一档且不可配**。两者在准入语义上完全同构(都不发请求、都带 `retry_after` 提示),处置却分叉。
**这一格的缺失与源数量无关**:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此**任何形态的单源特判(`if len(sources) == 1`)都是错的**——它会让行为随池大小突变、无法组合测试,是比现状更重的债。
### 1.2 缺陷二: `retry_after_s` 在 HALF_OPEN 下返回了一个物理上无意义的数
`try_enter` 在 HALF_OPEN 拒绝时返回 `probe_expires - now`,即**探针租约的剩余时长**。而 `probe_ttl_s` 派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`(`config.py:400-407`),现场 `TIMEOUT_S=300`**600 秒**,而冷却期只有 60 秒。
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(`backends/redis/breaker.py``TRY_ENTER`/`RETRY_AFTER` 两个 Lua 均返回 `probe_until - now`)。
### 1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
缺陷二的值被喂进了源冷却备忘:
```text
retry.py:354 self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
sources.py:139 self._until[name] = max(已有, until) # 取更晚者,不可回退
```
于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → **给 A 记 600 秒本地冷却** → 调用 1 的探针成功、门恢复 CLOSED → **本进程此后 600 秒仍然跳过 A**,且 `reasons[A]="cooldown"` 计入 `gate_rejections`,单源下每次调用照旧抛 `CircuitOpenError`
实测复现(`InMemoryGate` + 注入时钟,`cooldown_s=60``probe_ttl_s=600`):
```text
B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s
B 给 s1 记的本地冷却剩余: 600.0 秒
探针成功后门 state: closed
门已 CLOSED,memo.active('s1') = True
再过 120 秒(远超 60s 冷却)memo.active = True 剩余 480.0 秒
```
**这条与源数量、与是否单源都无关**:多源部署里,一个源每开路一次就会被本进程从池中除名 `probe_ttl_s`(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。
## 2. 备选方案与否决理由
issue 给了 A/B/C/D 四条。逐条判:
| 方案 | 判定 | 理由 |
|---|---|---|
| A `PGW_BREAKER_BACKEND=noop` | **否决** | 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决 |
| B `{SCOPE}__CIRCUIT_OPEN=wait\|fail_fast` | **采纳为主干** | 与 `quota_full` 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C |
| C 修 HALF_OPEN 的 `retry_after_s` | **采纳,且不是"治标"** | issue 把它列为"可并行的小修"。实际上它是 B 的**前提**:wait 档要按 `retry_after` 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根 |
| D 只写文档 | **否决** | 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 `force_open` |
**方案 = B + C,合并为一件事**:B 依赖 C 的正确性,C 修完 §1.3 自动消失。
## 3. 设计
### 3.1 `retry_after_s` 的契约定死为"确定的最早可尝试时刻"
| 门状态 | 返回值 | 依据 |
|---|---|---|
| CLOSED | `0.0` | 现状,不变 |
| OPEN | `open_until - now` | 现状,不变。冷却截止是确定时刻 |
| HALF_OPEN(被拒) | **`0.0`** | 探针随时可能出结果,**不存在**确定的等待时刻 |
`0.0` 不是新约定:`errors.py` 早已定义 `retry_after_s``0 = 可立即重试`,契约测试 `test_retry_after_semantics` 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。
信息不丢失:`GateDecision.state` 已经携带 `HALF_OPEN`,调用方要区分"门闭着"与"探针在途"照样能区分。
**惊群由既有机制承担,不由这个数承担**:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 `poll_interval_s` 抖动睡眠承担(§3.3)。
**§1.3 随之闭合**:`set_until(now + 0.0)` 写入一个已过期的截止时刻,`active()` 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 `retry.py` 加任何状态分支。备忘回归它唯一正当的用途:**记 OPEN 的确定冷却期**。
**准入被允许时恒 `0.0`**:`allowed=True` 意味着现在就能试,这个字段没有别的合理取值。
**改动面是五个出口,不是两个(Codex 审查修正)**。原稿只点了 `try_enter``retry_after_s()`,漏了 `GateUpdate` 那一侧;逐一核实后发现**两个后端在这两处本就已经分叉**——本 issue 的病根正是"`retry_after_s` 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:
| 出口 | memory 现状 | redis 现状 | 统一为 |
|---|---|---|---|
| `try_enter` 拒绝(OPEN) | `open_until - now` | 同 | 不变 |
| `try_enter` 拒绝(HALF_OPEN) | `probe_expires - now` | `probe_until - now` | **`0.0`** |
| `try_enter` **授予探针** | `0.0`(`memory:114`) | **`probe_ttl_ms`**(`redis:53`) | **`0.0`**(redis 侧改) |
| `GateUpdate`(fencing 未命中,HALF_OPEN) | `0.0`(`memory:175-177` 非 OPEN 一律 0) | **`probe_until - now`**(`redis:127/158/258`) | **`0.0`**(redis 侧三处改) |
| `retry_after_s()` 跨源取 min | HALF_OPEN 记 `probe_expires - now` | 同 | **HALF_OPEN 记 `0.0`** |
后两行是**既有缺陷**,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。
memory 侧抽 `_remaining(g)` 私有纯方法供三处共用;redis 侧四个 Lua(`TRY_ENTER`/`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE`)与 `RETRY_AFTER` 各改一处(Lua 无法共享函数,这是既有约束,`_WINDOW_HELPERS` 已是同款处理),由同一批双后端参数化契约用例锁死。
### 3.2 新配置键 `{SCOPE}__CIRCUIT_OPEN`
`quota_full` 逐项对齐,不发明新形状:
| 维度 | `quota_full`(既有) | `circuit_open`(新增) |
|---|---|---|
| 合法域 | `_QUOTA_FULL = {"wait","fail_fast"}` | `_CIRCUIT_OPEN = {"wait","fail_fast"}` |
| 缺省 | `wait` | **`fail_fast`**(见 §3.5) |
| env 键 | `{SCOPE}__QUOTA_FULL` | `{SCOPE}__CIRCUIT_OPEN` |
| 装配 | settings → `GatewayClient` → 三条循环 | 同 |
| 校验 | `_validate_backends` 表驱动 + 构造期 | 同(各加一行) |
改动面: `config.py`(常量 / 字段 / 校验元组 / `from_env` 各一行)、`client.py`(签名 + 透传各一处)、`SourceAdmission`(§3.4)一处。
### 3.3 `_on_no_runnable` 的控制流
现状两个分支是**串行**的。今天走不到那个坑(没有 wait 档,第一分支必抛),但**只要把第一分支改成"wait 时不抛"就会立刻踩中**:控制流会往下掉进 `quota_full` 分支,`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。必须改成按拒绝原因分派:
```text
if gate_rejections == len(sources): # 全部因熔断类原因被拒
if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
hint = await gate.retry_after_s(names) # OPEN 有确定值;全 HALF_OPEN 得 0
else: # 至少一源是被配额/AIMD 挡的
if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
hint = 0.0
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
await self._sleep(self._nap(hint, clock))
```
睡眠时长 `_nap(hint, clock)`,三条约束同时满足:
| 约束 | 实现 | 理由 |
|---|---|---|
| 不空转 | `hint > 0` 时睡到冷却结束再加抖动,而非 50ms 轮询 | 60 秒冷却下,`poll_interval=0.05` 会产生 1200 次无谓复查;memory 后端只是字典查询,**redis 后端是 1200 次往返 × 每个在途调用** |
| 不白醒 | 抖动**上**加(`hint + poll_interval × (0.5+0.5×rng)`),不缩放 | 对一个确定的截止时刻提前醒必然被再拒一次 |
| 等待有可解释上界 | 夹到剩余 stall 预算:`min(睡眠, stall_window - clock.stalled_s())`,下界 `poll_interval` | 最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = `stall_window_s`(缺省 300s),不随 `max_cooldown_s` 漂移 |
`hint = 0` 时该式退化为现有的 `poll_interval × (0.5+0.5×rng)`,配额等待路径逐字不变。
**计时归属无需改动**:这段睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。
### 3.4 前置收敛: 准入逻辑三处复制归一
`_pick_runnable` / `_on_no_runnable` 目前在 `middleware/retry.py``embedding.py``ocr.py` **各有一份**,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——**那才是本次真正会留下的技术债**(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。
`middleware/admission.py::SourceAdmission`,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 `pick()``on_no_runnable()`。三条循环的差异用注入表达,不留分支:
| 差异 | 处理 | 行为等价性 |
|---|---|---|
| 调用内降权(仅 chat) | `attempt_fails``pick()` 入参 | embedding/ocr 传空 dict 时 `_demote_call_failures` 恒等返回原序(`demoted` 为空即 `return ordered`) |
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit`/`enter`,无副作用 |
| `_settle_and_release` 三份复制 | 提为 `middleware/` 模块级 async 函数 | chat/embedding 签名为 `(permit, actual)`,**OCR 为 `(permit)` 且体内恒 `settle(0)`**(`ocr.py:438`,Codex 审查补)。OCR 侧改为传 `0`,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一 |
已逐字 diff 核实(`embedding``ocr` 两份**完全相同**;chat 多出的只有上表三类)。另有两处**不在抽取边界内**、须原样保留:chat 主循环顶部额外的一次 `_stalled` 预判(`retry.py:286`),以及 OCR 的健康喂数——它们属于各自的主循环与 `_attempt`,本次一行不动。
**这不是任务外重构**:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——`StallClock``backoff_delay` 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,**`_attempt` 一行不动**(三者差异大: 流式 / 批 / 图)。
执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。
### 3.5 缺省值取 `fail_fast`
`quota_full` 缺省 `wait`,但 `circuit_open` **不跟随**,理由是变更方向的危险性不对称:
| 取值 | 对存量下游的影响 |
|---|---|
| `fail_fast`(采纳) | **控制流**逐字不变(全源被熔断拒仍当场抛 `CircuitOpenError`) |
| `wait` | 把所有人的最坏墙钟从毫秒抬到 `stall_window_s`,且是"快速失败 → 长时间挂起"这个最危险的方向 |
issue 的诉求本身也不是改默认值,而是**表达能力**——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且**不允许调用方表达自己属于哪一种**"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 `wait` 即可。README 与 wiki 需明写"单源 scope 建议配 `wait`"。
### 3.6 `errors.py` 的职责边界补写
issue 要求修订 `GatewayUnavailableError` 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待**全部在库内**,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同。
这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。
**"缺省档零感知"须诚实收窄(Codex 审查修正)**: 缺省档保证的是**控制流**不变,不是零可见变更。`retry_after_s` 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 `CircuitOpenError.retry_after_s` 由"探针租约剩余"变为 `0.0`,而它是公开字段(`errors.py:118`)。这正是本次记 **1.3.0** 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 `GatewaySettings` 全部字段均无默认值(既有风格),新增 `circuit_open` 沿用之,直接构造该类的调用方须补一个参数。
## 4. 行为矩阵
| 场景 | `fail_fast`(缺省,= 现状) | `wait` |
|---|---|---|
| 单源 OPEN,冷却 60s | 立即 `CircuitOpenError(retry_after=剩余冷却)` | 睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回 |
| 单源 `force_open`(401/403) | 立即失败 | 等 60 → 探针又 401(**烧掉一格 `max_attempts`**)→ 等 120 → …… 以**先耗尽的那个预算**的 reason 失败: `max_attempts` 先尽则 `retry_exhausted`,冷却累计超过 stall 预算则 `stalled`。**代价须进文档** |
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(`retry_after_s` 取 min) |
| 全部 HALF_OPEN(探针在途) | `CircuitOpenError(retry_after=0)`,语义准确(随时可能好) | `poll_interval` 抖动复查,秒级拿到探针结果 |
| 配额满 / AIMD 超限 | 归 `quota_full` 管,逐字不变 | 逐字不变 |
## 5. 测试策略
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:
**契约层**(`tests/contracts/test_breaker_contract.py`,双后端参数化自动覆盖 memory + redis):
按 §3.1 那张表**逐个出口**钉——HALF_OPEN 被拒、授予探针、`GateUpdate` fencing 未命中、`retry_after_s()` 探针在途,四处均须 `== 0.0`;OPEN 语义不变(现有 `test_retry_after_semantics` 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 `tests/integration/test_redis_governance_time.py` 的真实等待变体(既有约定,不缩放时长)。
**单元层**(`tests/unit/test_backpressure.py` 邻域,注入时钟/睡眠/rng):
§1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);`circuit_open=wait` 下全源开路不抛 `CircuitOpenError` 而按 `retry_after` 睡;`wait` + `quota_full=fail_fast` 组合下熔断等待**不**被误报成 `quota_exhausted`(§3.3 那个坑的钉子);`wait` 档最坏墙钟 ≤ `stall_window_s` 且判死 reason 为 `stalled``per_source_reasons``circuit_open`;`fail_fast` 缺省下全部现有用例逐字绿。
**收敛层**: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。
## 6. 非功能与已知取舍
| 维度 | 结论 |
|---|---|
| 取消穿透 | `_nap` 的长睡眠是 `await self._sleep(...)`,`CancelledError` 逐字穿透;无新增 finally 资源 |
| 后端往返 | wait 档每个冷却周期约 1 次 gate 查询(vs. `poll_interval` 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait |
| 遥测 | **不加列**。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 `logger.info`(scope、per-source reasons、预计等待),使"等了多久"可从日志还原 |
| 等待上界的精确值 | `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 `stalled_s()` 等于窗口而不大于,不判死。故 `_nap` 夹到 `剩余预算 + poll_interval_s`,一次到位;最坏墙钟精确表述为 `stall_window_s + 一个 poll 间隔`,不是"恰好 stall_window_s" |
| 备忘的跨进程滞后 | 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(`_pick_runnable` 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),**既有性质、本次不改**;备忘是进程内存,无持久化,故不存在滚动升级残留 |
| 无限等待 | `_stalled` 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 `asyncio.wait_for` |
| 未解决 | `force_open` 在 wait 档下把坏密钥的失败从毫秒拖长(上限 stall 窗口)。**有意不特判**——库无法区分"密钥坏了"与"中转抖了",选 `wait` 即声明"宁可等也不当场死" |
| 两个预算并行(整分支审查发现,2026-08-20) | `wait` **不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `max_attempts`(issue #8 的划分依据是"谁消耗重试预算",探针发出了真实请求,理应记在重试预算上)。故 force_open 的源常以 `retry_exhausted` 而非 `stalled` 结束。原稿 §4 只写了 stall 一种结局,已更正;由 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉住 |
## 7. 文档与发布
ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 `docs-convention.md` §2 同步;CHANGELOG 记为 **1.3.0**(新增配置键 + `retry_after_s` 语义变更,后者对下游可见,需"请先读这一条"待遇)。
`GateDecision` 的字段与 `ProviderGate` 端口签名**均不变**,故不触碰迁移兼容约束(ARCH §5.1)。
## 8. 已定决策(人类,2026-08-19)
| # | 决策 | 随之固定的实施边界 |
|---|---|---|
| 1 | 缺省取 **`fail_fast`**(§3.5) | 存量下游零感知;issue 提交方需自行加 `{SCOPE}__CIRCUIT_OPEN=wait`。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在 |
| 2 | §3.4 的三处收敛**本次一并做** | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |
@@ -0,0 +1,282 @@
# 遥测连接池的资源语义与生命周期: 从"预占 10 条"到"按需 0 条"
- **issue**: #15(共享 PostgreSQL 实例,`max_connections=100`,多 worker × 多 scope 部署)
- **核查基准**: HEAD 1.2.4。issue 按 1.1.2 运行环境提交并已自行复核 1.2.4,本文逐条重核**全部成立**: `postgres.py:100`(建池不传 min/max)、`postgres.py:106`(建池失败即永久判死)、`postgres.py:246-251`(`aclose` 不清 `_failed`)、`client.py:405-420`(每个 client 各 new 一个 recorder)。`min_size`/`max_size` 在整个包内**一次都没出现过**。
- **状态**: **已实施**(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`,T0–T7 见实现计划末尾的提交表)。人类已确认方案与全部四组改动 + 缺省值;**Codex 已审,7 条全部处置完毕(§9)**;实施期的三处修订以 §10 标注
- **实测环境**: asyncpg 0.31.0;真实实验室 PG(`polygateway` 专用库,跨内网 RTT ≈ 123ms)
## 1. 问题的真实形状
issue 把问题命名为"asyncpg 默认 `min_size=10` 太大"。这个命名会把方案引向"改个默认值"。实际是**四层缺陷叠加**,只改默认值会留下三层,且下一次换个瞬时错误(PG 重启、DNS 抖动)照样全量失遥测。必须分开命名。
### 1.1 前提实测: `min_size` 的语义是"预连接",不是"下限"
asyncpg `pool.py:457``if self._minsize:` ——为 0 时 `_initialize` 只创建 holder 对象,**一条连接都不连**。由此实测得到本设计的全部地基:
| 实测项 | `min_size=0, max_size=2` | 默认 `10/10`(现状) |
|---|---|---|
| 建池指向**不可达**端口 | **立即成功**,0.000s,`size=0` | 立即抛 `ConnectionRefusedError`**issue 的失败点** |
| 建池连真实库 | 0.000s,`size=0` | 0.72s,**10 条常驻** |
| 首次写入 / 稳态写入 | 513ms(含建连 ≈390ms)/ **123ms**(一次 RTT) | 同(稳态无差异) |
| `acquire` 失败后再 `acquire` | 照常重试,池不进坏状态 | — |
| 空闲超 `max_inactive_connection_lifetime` | 连接归 0,下次写入重连 | 同 |
**关键推论**: `min_size=0` 不只是"调小",它把建池从一次全有全无的重资源动作变成**零成本、不触库**的动作。这一步走出去,后面三层的性质全变。
**实施后在同一台真实实验室 PG 上的复测(T6,按唯一 `application_name` 过滤 `pg_stat_activity`)**,是全套证据里最直观的一条: 修复前建完 recorder 即 **10** 条连接;修复后 **0**(建 recorder)→ **1**(一次写入)→ **4**(20 行并发,恰为 `pool_max`)→ **0**(`aclose` 后)。四个数字逐一对应上表的四行推论。
### 1.2 缺陷一: 库对自己的资源占用从未表态——而这是全库唯一一处
`create_pool(self._dsn, timeout=10)` 继承第三方默认值(P4/P5: 默认参数掩盖关键逻辑)。横向扫过库内每一处外部资源:
| 组件 | 建连方式 | 上限 | 预占? |
|---|---|---|---|
| httpx transport(`openai_compat.py:301`) | 按需 | 100(httpx 缺省) | 否 |
| RedisLimiter / RedisGate / RedisCache | 按需 | 无上限(redis-py 缺省) | 否 |
| **PostgresRecorder** | **预占 10 条,否则建池失败** | 10 | **是** |
**库内每一处外部资源都是按需建立,唯独遥测池预占**。issue 那句"业务侧一条一条按需要,这个池要么一次拿到 10 条、要么建池失败,所以余量紧张时先倒下的必然是它"完全正确——它是链路上最脆的一环,承担的却是最不该悄悄失败的职责。issue 现场规模: 4 client × 10 = **40 条常驻专用于写遥测**,而实际写入并发是个位数。
### 1.3 缺陷二: 判死判据挂在"哪一步失败",而非"失败是什么性质"
issue #9 已把判死收窄为"确定写不进去",但漏了一格: `_open_pool` 这一步里**同时藏着两类失败**——DSN 本身非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。因为 `min_size=10` 让瞬时错误**发生在建池这一步**,它就被 `postgres.py:106` 一刀切成了永久判死。
判据错位的证据: `postgres.py:104-105` 的注释"池建不出来 = 确定写不进去"——这句话在 `min_size=10` 下是**假的**(连接耗尽不是确定写不进去,是这一秒写不进去);在 `min_size=0` 下才为真。**注释描述的是设计意图,代码实现的是另一件事**,中间的差额就是这次事故。
### 1.4 缺陷三: 降级不可恢复,且不可见
| 性质 | 现状 | 后果 |
|---|---|---|
| 不可恢复 | `_failed` 置位后无任何恢复路径;`aclose()`(`postgres.py:246-251`)只清 `_schema_ready` **不清 `_failed`** | 只有进程重启能恢复 |
| 不可见 | 全程只有**一条** warning(`postgres.py:107`) | 长跑进程里等同于静默 |
issue 是**手工对账**(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,期间 19 次调用一行未落、成本少记约 $5。这就是"遥测必录"铁律的实质破口: 库做不到必录时,必须**持续、可编程地**让下游知道。SQLite 侧更糟——`sqlite.py:138-139` 初始化失败后写入直接 `return`,**连 warning 都没有**。
### 1.5 缺陷四: 共享路径是坏的,所以每个 client 只能各占一份
issue 建议"让指向同一 DSN 的多个 recorder 共享一个池"。这条路今天走不通,而且不通的原因是一个**跨组件的所有权纪律缺口**:
| 现象 | 位置 | 性质 |
|---|---|---|
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry → 共享 recorder 被第一个关闭的 client 弄死 | `client.py:271-273`(`embedding.py:455-461``ocr.py:465-467` 各有一份复制) | 越权 |
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不碰 limiter/breaker) | `client.py:263-280` | **泄漏** |
| 对照组: `RedisLimiter._owns_client` 纪律**是对的** | `limiter.py:185-191, 318-322` | 正确先例 |
**实施期挖出的第四个现象(T4,本设计原稿未预见)**: 注入外部池时,`aclose()` 之后的下一次写入会拿 DSN **偷偷自建一个池**——注入方以为自己管着全部连接,实际早已不是。它与上表三条同一根因(库不区分"这个资源是谁的"),只是表现在**关闭之后**而非关闭当时,故原稿按"谁关谁的"扫一遍时没看见。修法归入 §3.2 第 4 点的"关了就是关了": 置 `_closed` 后写入短路且不复活。
三个现象一个根因: **库对"谁建的、谁负责关"没有统一纪律**。ARCH §7.7 R5 规定"共享必须显式注入",但显式注入这条正道今天是坏的,下游只能退回"每 client 各占一份"——缺陷一的放大器由此长在架构里,而不是长在某个默认值里。
## 2. 备选方案与否决理由
| 备选 | 否决理由 |
|---|---|
| 只把默认值调小(issue 方向 1 单独做) | 脆点消失,但 §1.3 的判据错位仍在: 下次 PG 重启/DNS 抖动落在准备期,照样永久失能。治标 |
| 只加建池退避重试(issue 方向 3 单独做) | 在错的地方加复杂度。`min_size=0` 之后建池已不触库,**没有可重试的失败**;真正需要重试的是 acquire,而那里本来就有正确行为 |
| 隐式全局池注册表(DSN → 共享池) | 违反"纯 asyncio 中立: 无全局状态、无模块级单例"铁律,且解决的是 `min_size=0` 之后已不存在的问题(闲时占 0) |
| 暴露 `min_size` 配置项 | 它唯一的作用是把脆点装回来,换取首次 390ms。库没有理由提供一个只会伤人的旋钮(P1+P5) |
| 遥测改异步队列 + 后台 flush | 真正彻底消除"遥测拖慢业务",但引入进程崩溃时的丢数据窗口——与遥测被下游当**审计证据**用(§7.8/issue #12 决策 E-a)正面冲突;还要背负后台任务生命周期与背压策略。重大架构变更,不在本 issue 换取的收益内 |
| 把遥测失败塞进 `errors.py` 四分类 | 四分类的语义是"决定重试/换源/熔断"(ARCH §5.1)。遥测失败既不冒泡也不参与那套决策,塞进去会污染分类语义。改为在遥测子系统内定义自己的三分,收敛在一处(§3.2) |
## 3. 设计
### 3.1 A 组 · 池语义: 显式声明,按需建连
`create_pool(dsn, min_size=0, max_size=<配置>, timeout=<写入预算>, command_timeout=<写入预算>)`
- **只暴露 `max_size`**(理由见 §2)。稳态占用从"40 条常驻"变成"实际并发,闲时 0"。
- 整次写入(`_ensure_ready` + `acquire` + `execute`)由 `asyncio.timeout` 包一层**硬预算**,超时按行级丢弃。这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证。
- `acquire` 必须显式传 timeout。今天 `postgres.py:238``pool.acquire()` **无超时**(asyncpg 缺省 `timeout=None` = 无限等待),池满时会无限期挂在业务路径上——现状因 `max_size=10` 而未暴露,`max_size=4` 后必须补齐。
- **不得用 `async with pool.acquire(...)`(Codex 审查,2026-08-24,已核实)**`Pool.release()``await asyncio.shield(ch.release(timeout))`,且该 timeout **默认取 acquire 时记录的 `ch._timeout`**(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `async with``__aexit__`,此时**没有新的 cancel 投递**,那个 shielded release 会正常等到完成——于是业务路径的真实上界是 **≈ 2 × 预算**,而不是文档原先承诺的一个预算。故改为显式 `con = await pool.acquire(timeout=self._write_timeout_s)` + `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`。**acquire 传的是完整预算而非剩余预算**(实施期核定,T3): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍,徒增出错面;实测总耗时正好等于预算。承诺相应精确化为: **主写入尝试 ≤ 预算,释放路径独立有界**
- `CancelledError` 穿透由测试钉死: `asyncio.timeout` 只把自己触发的 cancel 转成 `TimeoutError`,外部取消照常以 `CancelledError` 冒出(实测确认,Codex 独立复现)。**实现纪律**: 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`(铁律"取消可穿透")。
### 3.2 B 组 · 失败三分与冷却降级
**判据(两句,写进 ARCH)**:
1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
第 2 句是 Codex 审查(2026-08-24)后补的,**原稿只有第 1 句,而分类表把 SQLSTATE `42` 整类归了行级——这与第 1 句自相矛盾**: 42501(账号被收走 INSERT 权限)、42P01(表被迁走/删掉)都是"能被外部修好"的持续性状态,却要在每次 LLM 调用上内联付一次 ≈123ms 往返并刷一条 warning,永远不会自愈也永远不停。按 SQLSTATE 前两位切太粗,必须切到具体码。
归档(asyncpg 0.31 异常层次 + PG SQLSTATE,**按 SQLSTATE 分类而非异常类白名单**——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移):
| 档 | 判据 | 处置 |
|---|---|---|
| **配置级致命** | `ClientConfigurationError`(DSN 本身不可解析,`InterfaceError`/`ValueError` 子类);`create_pool` 抛的 `ValueError`/`TypeError`(参数非法) | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
| **环境级不可用** | SQLSTATE `08`(连接)/`53`(资源不足,含 **53300 too many connections**)/`57`(管理干预)/`28`(认证)/`3D`(库不存在)/**`42501`(无权限)**/**`42P01`(表不存在)**;`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅准备期路径可达**——写入期的超时被 `record_llm_call``except TimeoutError` 先接住并按行级丢弃,见第 3 点);**表确定不存在且建不出来** | **冷却降级**(内部常量 60s),到期允许**一次**重新准备 |
| **行级拒绝** | 其余 `PostgresError`: 数据与约束类(`22`/`23` 等),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级(`postgres.py:242-244`),但**接入节流复述** |
四点必须说清:
1. **致命档收到极窄是有意的**。认证失败、库不存在、表建不出来一律归环境级——它们都是外部状态,DBA 改完密码/建完表就该自动恢复。永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形,而 DSN 是构造期固定的字符串,是唯一满足这条的东西。
2. **`42703` 是判据的唯一具名例外,且必须写明理由**。按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了一条更高优先级的承诺: manual 档缺列时**按现有列裁剪 INSERT 继续写**,缺列以逐行 warning 暴露,好让下游发现 schema 漂移——即"部分列写进去了"这件事本身有价值,不该被冷却掉。代价(无限逐行 warning)由接入节流复述抵消。**例外只此一条,新增例外必须同款论证**。
3. **带冷却正面回答了 `postgres.py:104-105` 的顾虑**。那条注释担心的是"每次调用都内联吞一次 connect 超时";冷却 + §3.1 的硬预算把最坏成本变成"每 60s 一次、上界一个预算",有界且可解释。**进程不再需要重启**。
**这句承诺的适用范围是准备期路径**(2026-08-24 合并前审查校正,**只改文档不改行为**): `TimeoutError``OSError` 子类、本表据此归环境级,但 `record_llm_call``except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的超时一律在那里按行级丢弃,`_handle_failure` 根本不会被调用——写入路径上这条分类规则是死代码。于是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用仍内联付满一个预算(缺省 5s)、丢一行、`degraded` 保持 False、不进冷却。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行不置 degraded"是 §6"突发排队"与 ARCH §7.8"`degraded``dropped_rows` 覆盖的不是同一件事"那一条明确记下的有意取舍;升档议题见 §6 的"连续超预算丢行是否该升档"一格。
4. **`aclose()` 的语义钉死为"关了就是关了"**: 置 `_closed`,此后写入短路且**不复活**。今天"关完还能自己重建池"的灰色状态取消。issue 提的"`aclose` 不清 `_failed`"由冷却机制解决,不由 `aclose` 解决——恢复是运行时行为,不是关闭动作的副作用。
**关闭动作本身也必须有界(Codex 审查,已核实)**: `Pool.close()``await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(`pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着"advisable to use `asyncio.wait_for` to set a timeout"。故 `aclose()``asyncio.wait_for(pool.close(), ...)`,超时后 `pool.terminate()`,外部取消照常穿透——否则"遥测不得拖垮业务"在收尾路径上开了个口子。
分类函数是全库唯一一处 PG 失败分类,作 `postgres.py` 模块级私有函数(与 recorder 同文件、只服务 PG;不新起文件避免碎片化)。**认不出的失败归最轻档(行级)**是它的保守缺省,而这个缺省在**建池路径**上安全的理由比"最轻档代价最小"更强(实施期核实,T5): `min_size=0` 让建池不触库(实测 0.000s),所以"归行级 = 下次调用再重试一次建池"本身**零成本**——`postgres.py:104-105` 那条注释担心的"每次重试内联吞一次 connect 超时"是 `min_size=10` 语义下的顾虑,在新语义下**不成立**。这是 §1.1 那个关键推论的又一处红利: 地基一换,原本需要小心处理的保守缺省变成了白拿。
### 3.3 C 组 · 降级可见 + 可编程
新增 `telemetry/status.py``TelemetryStatusTracker`(两个 recorder **共用**,消除两侧不对称):
| 能力 | 行为 |
|---|---|
| 进入降级 | 一条日志,含原因分档与恢复条件(冷却剩余 / "需重启");**级别由 `fatal` 决定且只在这一处决定**——致命档 error(人配错了,不会自愈)、其余 warning。recorder 侧不得再复制一条(实施期更正 #4) |
| 降级期间 | 按丢弃行数与时间**节流复述**(不刷屏,也不静默)——这一条是 §1.4 的直接钉子 |
| 恢复 | info 一条,报告"期间丢弃 N 行" |
| 快照 | `TelemetryStatus` frozen dataclass(放 `types.py`,与 `SourceStats` 同一先例): `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s` |
**不叫 `health`,是因为这个词在 `ports.py` 里已经被占用两次**(本轮自查发现,Codex 未提): `OcrTransport.check_health`(`ports.py:90`,源探活)与 `SourceSelector` 侧的 `health(source_name) -> float`(`ports.py:234`,成功率 EWMA)。库内 `health` 一律指**源的健康度**,而这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分。同一文件里一词两义会直接违反 P2(领域术语命名)。
**不并入 `TelemetryRecorder` 主 Protocol(Codex 审查,已核实)**: 该 Protocol 是 `@runtime_checkable`(`ports.py:246`),而 runtime 检查按属性存在性做——加一个 `status` 属性,会让所有只实现 `record_llm_call` 的实现**当场不再是** `TelemetryRecorder`。库内 `tests/unit/test_ports.py:137,141` 就有 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言,下游若用同款断言,升级即断。原稿"库外无第三方实现者故加属性零成本"的判断**只覆盖了静态类型,漏了运行时结构契约**。改为:
- 独立可选端口 `TelemetryStatusProvider`(单方法/单属性,`@runtime_checkable`),两个内置 recorder 实现它;`TelemetryRecorder` 逐字不动。
- 出口 `GatewayClient.telemetry_status -> TelemetryStatus | None`(None = 未启用遥测,或注入的 recorder 不提供)。取值经**一处** `isinstance(..., TelemetryStatusProvider)` 判定,不重演 `aclose` 那种三处复制的鸭子类型。
- `types.py``ports.py` 同层且允许互 import(import-linter `ports : types : errors` 契约),分层不破。
- 时钟经构造参数注入(`now: Callable[[], float] = time.monotonic`,与 `GatewayClient(now=...)` 同款),冷却与节流均可测。快照对外给 `degraded_for_s` **相对时长**而非绝对时间戳,避免 monotonic 与 wall clock 两个时钟并存的二义。
- **SQLite 侧本次只做可见性**(补上缺失的 warning + 接入 tracker + 快照),**不做** lazy 化与冷却重连。理由: SQLite 的失败模式(本地目录不可写、文件损坏)在装配期就会暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确;lazy 化是独立重构。tracker 与快照两侧共用,将来若要对称,接口已就位。
### 3.4 D 组 · 资源所有权纪律统一
`RedisLimiter._owns_client` 这个**库内已有的正确先例**推广为全库唯一纪律: **谁建的谁关,注入的一律不碰**。区分两类:
| 类 | 所有权归属 | 落法 |
|---|---|---|
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 → **`RedisCache` 补齐这条纪律** |
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性(与 `RedisLimiter.from_url:190` 逐字同款模式),`aclose` 只关自建的 |
- **默认必须是"不拥有"**: `__init__` 是全量注入路径(`client.py:126-149`),经它传入的一切组件一律视为**外部所有**(`_owns_* = False`),只有三个工厂在 `or _build_*` / `if telemetry is not None else _build_telemetry` 真正自建时才置 True。原稿只写了"工厂置位"没写死这条默认,Codex 据此指出直接构造路径下共享 transport 仍会被第一个 client 关掉——那是实现走偏的后果,但默认值本就该在设计里定死,故补。
- 三处复制的 `getattr(..., "aclose")` 收敛为一个内部 helper;所有权修正必须三处一致,复制就是下一个 bug 的种子。
- `aclose` 补关 limiter/breaker——修掉现存泄漏。这需要**三个 client 都新持引用**: 今天 `GatewayClient.__init__` 把 limiter/breaker 交给 `RetryMW` 后自己不留引用(`client.py:133-134`),embedding/ocr 同样(`embedding.py:497-498``ocr.py:510-511` 自建、`embedding.py:452-461``ocr.py:462-467``aclose` 触达不到)。内存后端无 `aclose`,helper 探测后跳过。
- **判定一律用 `is None` / `is not None`,不得用 `or`**(实施期补,T1): 工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好,故写进设计而非留在代码里。
- **零公共 API 面变化**: `_owns_*` 是私有属性,由工厂置位。
- 有了 D 组,issue 的"共享池"方向以**显式注入**形态自然成立(`PostgresRecorder(dsn, pool=...)` 已支持且不关外部池),无需任何隐式全局。
### 3.5 新配置键与缺省值(人类已定)
| 键 | 字段 | 缺省 | 依据 |
|---|---|---|---|
| `PGW_TELEMETRY_PG_POOL_MAX` | `telemetry_pg_pool_max: int` | **4** | 稳态吞吐**实测约 15.6 行/秒**(见 §6 的口径更正;原稿按 `max_size / RTT` 估的 32 行/秒偏乐观一倍),覆盖单 client 十余并发;闲时占 0,不构成常驻负担。issue 现场 4 client × 4 = 峰值 16、稳态趋近 0(今天是 40 条常驻) |
| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | `telemetry_pg_write_timeout_s: float` | **5.0** | 实测稳态 123ms、首次含建连 513ms;5s 宽松且**有界**。同时用作 connect / acquire / 整次写入硬上界 |
| (无键) | 冷却期 | 60s,**内部常量** | 无部署差异理由(P1 YAGNI) |
- 两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致,语义无歧义: SQLite 侧的等价物(`busy_timeout=5000`,`sqlite.py:58`)本次不动,这个不对称是**已知且有理由**的(见 §3.3 末)。
- 校验落 `GatewaySettings._validate_telemetry`(与 `telemetry_text_cap` 同一先例,覆盖直接构造 / `dataclasses.replace` / env 三条路): `pool_max >= 1``write_timeout_s > 0`,报错文本同时点字段名与 env 键名。
- 加字段的代价可控: `GatewaySettings(` 全库**只有 1 处**构造(`config.py``_load_pgw`),测试全走 `from_env`(74 处)+ `replace`(53 处),不重演 issue #13 那 35 处直接构造点的代价。三条链路(chat/embedding/ocr)因共用 `GatewaySettings` + `_build_telemetry` 自动覆盖。
## 4. 行为矩阵
| 场景 | 现状(1.2.4) | 本设计 |
|---|---|---|
| 建 client,共享实例余量 3 条 | 建池失败 → **整进程永久失遥测** | 建池成功(不触库),写入按需拿 1 条 → **正常落库** |
| 稳态写入 | 10 条常驻 | 闲时 0 条,忙时 ≤ `pool_max` |
| `too many clients` 落在首次准备期 | 永久判死 | 冷却降级 60s → 到期重试 → **自动恢复** |
| `too many clients` 落在稳态写入 | 丢一行,池自恢复(已正确) | 同,且进入降级态使其**可见** |
| PG 重启 / 网络抖动 | 视落点: 准备期 → 永久判死 | 一律冷却降级 → 自动恢复 |
| DSN 写错 | 永久 no-op + warning | 永久 no-op + **error**(措辞点明"配置错,需改 DSN 并重启") |
| 旧表缺列(42703) | 逐行 warning 丢弃 | 逐字不变(行级档) |
| 表不存在且建不出来 | 永久判死 | 冷却降级,DBA 建表后**自动恢复** |
| 遥测后端慢/挂 | `acquire` 无超时,可无限期挂在业务路径 | 硬预算封顶(5s),超时丢一行 |
| 降级期间下游想知道 | 只能人肉对账 | `client.telemetry_status` + 节流复述日志 |
| 多 client 注入同一 recorder | 第一个 `aclose` 把它弄死 | 各关自己的,共享 recorder 存活 |
| 自建 redis limiter | `aclose` 后**泄漏** | 被关 |
| 注入 redis 客户端给 RedisCache | 被 `aclose` 误关 | 不动 |
## 5. 测试策略
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。
**单元层**(`tests/unit/test_telemetry.py` 邻域,沿用既有假 asyncpg 模块):
建池参数断言 `min_size == 0``max_size == 配置值`(钉住"库对资源占用的表态",防回归到继承第三方默认值,这是本 issue 的**主回归钉子**);`too many clients`(53300)落在准备期 → 进冷却降级、**不** fatal → 假时钟推进 60s → 自动恢复;`ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);**`42501`/`42P01` → 进冷却降级**、`42703` → 行级丢弃且**不**进降级(§3.2 的分档边界,两侧各钉一次);假 pool 的 acquire 挂住 → 硬预算生效、丢一行、耗时 ≤ 预算;外部 `CancelledError``asyncio.timeout` 内**不**被吞成 `TimeoutError`;状态快照六字段的状态机;节流复述(N 条丢弃只出 M 条 warning,loguru sink 断言);`aclose` 后写入不复活。
**收尾路径层**(Codex 审查新增,两条都是"看起来完成了、其实资源还在"的形态):
`execute` 被硬预算取消后**连接不泄漏**——假 pool 记录 acquire/release 配对次数,断言超时路径上 release 照样发生且总耗时 ≤ 预算 + release 上限(钉 §3.1 那条 shielded release 的坑);② 持有连接不释放时 `aclose()` **不无限挂**——假 holder 永不 release,断言 `aclose` 在超时后走 `terminate()` 返回。
**所有权层**(`tests/unit/test_client.py` 邻域,假 recorder/transport 记 close 次数):
注入的 recorder/transport/limiter/breaker/cache 不被 `aclose` 关;自建的被关;自建 redis limiter/breaker 被关(泄漏钉子);注入给 `RedisCache` 的客户端不被关;三个 client(chat/embedding/ocr)**逐一**覆盖——收敛成 helper 后仍须三处各钉一次,否则下次复制回来无人发现。
**契约层**: `isinstance(只实现 record_llm_call 的对象, TelemetryRecorder)` 必须**仍为 True**(`tests/unit/test_ports.py:137,141` 现有断言保持绿即可,不需新增)——它是"没把 `status` 并进主 Protocol"这条决策的机械化执法点。
**集成层**(`tests/integration/test_postgres_telemetry.py`,真实 PG,沿用 run 级前缀隔离与"严禁 DROP/TRUNCATE"纪律,缺 DSN 则 skip、不标 slow):
`pg_stat_activity` 计数——建 recorder 后本池连接 **0** 条,一次写入后 **≤1** 条(issue 的直接回归钉子);稳态连接数 ≤ `pool_max`。**计数必须按唯一 `application_name` 过滤**(经 `server_settings` 设一个 run 级值): 该实例被多项目共用,按库名或用户名计数会被别人的连接污染,那样的用例是设计上就会间歇红的信号污染源(CLAUDE.md §4.6)。降级与恢复走**不可达 DSN** 的 recorder 验证,不去动共享实例的 `max_connections`
## 6. 非功能与已知取舍
| 维度 | 结论 |
|---|---|
| 首次写入延迟 | `min_size=0` 把 ≈390ms 建连从"装配期"挪到"首次写入"。稳态无差异(实测 123ms);空闲超 `max_inactive_connection_lifetime`(asyncpg 缺省 300s,不暴露)后再付一次。相对一次秒级 LLM 调用可忽略 |
| 突发排队(**热池稳态**) | 业务并发 > `pool_max` 时遥测写入排队。按下一格更正后的实测口径(15.6 行/秒): 50 行同时到达 → 实测 3.2s,在 5s 预算内但**余量只剩约 1.5 倍**(原稿按 32 行/秒估算时以为余量有 3 倍);超出即丢行(铁律"丢一条 < 拖垮调用") |
| 突发排队(**冷启动/空闲后**) | 上一格的算术只在"schema 已就绪且连接已热"时成立。空闲超回收期后连接归 0,第一波要重新建连(实测 ≈390ms),且首次准备被 `_init_lock`(`postgres.py:75, 83-91`)串行保护——冷启动的最坏延迟不是 `64 / 32 ≈ 2s`。Codex 审查指出原稿这段易被读成两种情形通用,故拆开写。冷启动上界仍由硬预算封顶,超出即丢行 |
| Python 版本 | **本条取舍已消解**(人类决策,2026-08-24): 最低版本提到 **3.12**(`requires-python = ">=3.12"`、ruff `target-version = "py312"`),3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,`asyncio.timeout` 可直接用,不必退回 `wait_for`。代价见 §7 |
| `pool_max` 的调参口径(**实施期更正,T3 实测**) | 原稿的 `期望吞吐 ≈ pool_max / RTT`(4/0.123 ≈ 32 行/秒)**偏乐观一倍**: T3 实测 50 行并发批耗时 **3.2s**,即约 **15.6 行/秒**、每条连接约 4 行/秒——一次 `INSERT` 的实际往返比一次 `SELECT 1`(RTT 的测法)重。取舍方向不变(超预算丢行 < 拖垮业务),但 `.env.example` 与 README 的调参口径**必须写实测数字**,否则下游按错公式放大,以为 `pool_max=8` 能到 64 行/秒(实为约 31)。共享一个 recorder 给多 client 时并发在此汇聚,应按 client 数相应放大 |
| 冷却期的丢数 | 降级 60s 期间的行**确实丢了**,只是可见、可计数、且到期自动恢复。这是"遥测降级不得拖垮业务"的既有方向(ARCH 降级方向铁律),本设计不改方向,只改**可恢复性与可见性** |
| `42703` 缺列的持续逐行重试 | 缺列时每次调用付一次 acquire+execute(≈123ms 内联)且逐行 warning,不进冷却。**这是判据的唯一具名例外**(§3.2 第 2 点),由 issue #13 的"缺列须逐行暴露"承诺定死;代价由节流复述抵消。`42501`/`42P01` 原稿同归此格,经 Codex 审查已改判环境级 |
| 快照计数的线程安全 | `dropped_rows` 是单事件循环内的 int 自增。库不承诺跨线程共享同一 recorder("纯 asyncio 中立"),最坏是计数不准,不会崩 |
| SQLite 侧不对称 | 只做可见性,不做 lazy 化/冷却(理由见 §3.3)。tracker 与快照两侧共用,不产生第二套概念 |
| redis / httpx 的资源上限 | 两者均无上限或偏大(§1.2),但**按需建连、无预占脆点**,不是本 issue 的病灶。列为观察项,**本次不动**(反 gold-plating) |
| 连续超预算丢行是否该升档(**留作后续议题**) | 后端假死(TCP 通但不回应)且 schema 已就绪时,每次业务调用都内联付满一个预算并丢一行,`degraded` 恒 False、永不进冷却(成因见 §3.2 第 3 点)。本次不改行为——相对改前的"无限期挂"已是净改善,而升档需要新判据("连续 N 次超预算 = 后端不可用"),那是个有代价的猜测: 判错会把本地并发过高误判成后端挂了,冷却 60s 只会白丢更多行。要动就得先有实测依据,不在本 issue 范围内 |
| 端口签名 | `TelemetryRecorder` **逐字不变**(24 字段签名与 Protocol 成员集合都不动),不触碰迁移兼容约束(ARCH §5.1)、也不破坏 `runtime_checkable` 的既有 `isinstance` 语义;新增的是**独立**端口 `TelemetryStatusProvider` |
## 7. 文档与发布
ARCH §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分判据(§3.2 那句判据是主要交付物之一)、**资源所有权纪律**(§3.4,应作为跨子系统的通用纪律成文,而非遥测局部约定)。§9 配置面登记两个新键。`.env.example`、README 能力表与配置表、Gitea wiki 按 `docs-convention.md` §2 同步。
**版号由人类在发布时定**,本文不预设: 按 semver 应是 **1.3.0**(端口新增只读属性 + 两处对下游可见的行为变更),但项目既有口径明显偏 patch——issue #11 扩遥测列(端口 22→24)落 1.2.1、issue #14 新增配置键 + `retry_after_s` 语义变更**设计文档写的是 1.3.0、实际发成了 1.2.4**。不核对这一条就照抄"1.3.0"会重演同一次不一致。
CHANGELOG 有四处需"请先读这一条"待遇(第 4 条是合并前审查补的):
1. **最低 Python 提到 3.12**(人类决策,2026-08-24;`requires-python`、ruff `target-version`、README、CLAUDE.md 四处已同步)。这是四处里**唯一会让下游装不上**的变更: 仍在 3.11 的部署 `pip install` 直接被 pip 拒绝。这一条本身就足以把版号推到 **1.3.0**——它不是"新增能力",是缩小了支持面。
2. 遥测常驻连接从 `10 × client 数` 变为按需(纯改善,但监控上会看到连接数曲线突变)。
3. `aclose` 不再关闭注入的组件。这是修正越权,但若有下游**依赖**了"注入后由 client 代关",升级后会漏关——必须显式声明。
4. **直接构造 `GatewaySettings` 需补两个参数**(合并前审查补,2026-08-24)。原稿漏了这一条,还把两个新字段写成"带缺省"——它们与相邻三个遥测键一样**无默认值**,缺省只在 env 装配路;直接构造的调用点升级即 `TypeError`,是货真价实的破坏性变更。
**版本提升的两项前置——已于 2026-08-24 执行完毕**(顺序不可颠倒,先改语法会当场把 import 全炸掉):
| # | 前置 | 结果 |
|---|---|---|
| 1 | 重建 conda 环境(原 3.11.15 不满足新的 `requires-python`,`make install` 会被 pip 拒绝) | `PolyGateway` 重建为 **3.12.13**;`make install` 通过。比对新旧 `pip freeze` 发现重建**只**缺发布工具链(`build`/`twine` 及依赖,不在 `make install` 的 extras 里),已补装(twine 7.0.0) |
| 2 | `target-version = "py312"` 启用 UP047,3 处须改 PEP 695 语法(该语法在 3.11 是 **SyntaxError**) | `gather_bounded`(`client.py:443`)、`_anext_within`(`streaming.py:39`)、`stream_with_liveness_timeouts`(`streaming.py:60`)改为 `def f[T](...)`;两文件的模块级 `_T = TypeVar("_T")``TypeVar` import 随之删除 |
验证: `make check` 全绿(ruff format + lint + import-linter 契约 KEPT),全套件 **973 passed / 23 skipped / 45 deselected(slow),覆盖率 94%**。这三处改动**不是本 issue 的重构**,是版本提升的直接后果,归入版本提升那个前置提交。
## 8. 已定决策(人类,2026-08-24)
| # | 决策 | 随之固定的实施边界 |
|---|---|---|
| 1 | C 组只读状态快照**要做** | 新增**独立**端口 `TelemetryStatusProvider`(`TelemetryRecorder` 不动,理由见 §3.3);`types.py``TelemetryStatus`;client 侧一处 `isinstance` 判定。命名避开 `health`(该词在 `ports.py` 已两处占用) |
| 2 | D 组(所有权纪律)**一并做** | 改动面从 telemetry 扩到 client/embedding/ocr/backends。拆为**独立前置提交**(纪律统一 + 泄漏修复),验收标准"全套件绿 + 新增用例只在所有权层",该提交即回滚点 |
| 3 | 缺省 `POOL_MAX=4` / `WRITE_TIMEOUT=5.0` | 按 §3.5 落 config 校验;README 须给出调参口径,否则这两个旋钮等于不存在。**人类当时定的公式 `pool_max ≈ 期望吞吐 × RTT` 已被 §10 修订 #1 作废**(偏乐观一倍),文档一律写实测值 15.6 行/秒 |
| 4 | **最低 Python 提到 3.12**,版号定 **1.3.0** | 消解 §6 的 `asyncio.timeout` 版本取舍(可直接用,不退回 `wait_for`)。两项前置(重建环境、UP047 三处改 PEP 695)**已执行完毕并验证**,详见 §7。版号 1.3.0 的依据是缩小支持面,不是新增能力 |
## 9. 审查留痕(Codex,2026-08-24)
报 3 阻断 + 3 应改 + 1 可选,**逐条独立核实后 6 条采纳、1 条改判**。采纳的都不是措辞问题,而是"承诺比实现能给的更强"这同一类错误的不同实例。
| # | 档 | 结论 | 落点 |
|---|---|---|---|
| 1 | 阻断 | **采纳**`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout,业务路径真实上界 ≈ 2 × 预算。核实于 `pool.py:886-889, 930-937` | §3.1 第 3 条;§5 收尾路径层① |
| 2 | 阻断 | **采纳,并回头改了判据本身**。SQLSTATE `42` 整类归行级与"能被外部修好的一律冷却重试"自相矛盾。补出第 2 句判据(行级 vs 环境级看"与本行数据有没有关系"),`42501`/`42P01` 改判环境级,`42703` 降为唯一具名例外 | §3.2 判据 2 与第 2 点;§5 单元层;§6 |
| 3 | 阻断 | **改判为实现约束**(非设计缺陷)。原稿"工厂置 `_owns_*`"已隐含"注入即不拥有",但确实没写死默认值。补为显式条款 | §3.4 第 1 条 |
| 4 | 应改 | **采纳,且原稿的理由本身是错的**。原稿称"库外无第三方实现者故加属性零成本"——这只覆盖静态类型,漏了 `TelemetryRecorder``@runtime_checkable`(`ports.py:246`),加属性会让 `tests/unit/test_ports.py:137,141``isinstance` 当场变 False。改为独立端口 | §3.3;§5 契约层;§6 |
| 5 | 应改 | **采纳**`Pool.close()` 等 in-flight 释放会无限挂,60s 只 warning(`pool.py:939-948, 961-972`) | §3.2 第 4 点;§5 收尾路径层② |
| 6 | 应改 | **采纳**。limiter/breaker 引用要传穿三个 client,原稿只写了 chat | §3.4 第 3 条 |
| 7 | 可选 | **采纳**。吞吐算术只对热池稳态成立,冷启动另有口径 | §6 |
**本轮自查另补两条 Codex 未发现的**: ① `health` 一词在 `ports.py` 已被 `check_health`(`:90`)与 `health(source_name) -> float`(`:234`)占用两次,故快照改名 `TelemetryStatus`(§3.3);② `asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一(§6)。
Codex 的取消穿透实测与本会话结论一致(外部 `task.cancel()``asyncio.timeout` 内冒出的是 `CancelledError` 而非 `TimeoutError`),两处独立验证互为佐证。
## 10. 实施期修订(2026-08-24,T0T7 执行中发现)
设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。
| # | 修订 | 落点 |
|---|---|---|
| 1 | **吞吐算术偏乐观一倍**。原稿按 `pool_max / RTT` 估 32 行/秒,T3 实测 50 行并发批 3.2s(≈15.6 行/秒)——`INSERT` 的实际往返比测 RTT 用的 `SELECT 1` 重。方向不变,但下游调参必须拿实测数字 | §3.5 表、§6 两格 |
| 2 | **原稿未预见的一处真 bug**: 注入外部池时 `aclose()` 之后的下一次写入会拿 DSN 偷偷自建一个池。与 §1.5 三条同根因,只是表现在关闭之后,T4 修掉 | §1.5 |
| 3 | **两条论证被补强**: ①"认不出的失败归行级"这个保守缺省在建池路径上安全,理由是 `min_size=0` 让重试建池零成本(T5);②所有权判定必须用 `is not None` 而非 `or`,否则注入 falsy 后端时自建分支与所有权标志漂移(T1) | §3.2 末、§3.4 |
| 4 | **日志级别的决策点收敛到 tracker**(独立验证发现)。原实现在 recorder 的 fatal 分支另发一条 `logger.error`,而 tracker 同时发一条语义重复的 warning——同一个事实两条日志,"级别"这个决策两个源头。改为 `enter_degraded``fatal` 选级别(error / warning),recorder 不再另发;SQLite 侧的致命档同步升为 error。**这条决策此前没有执法点**: 测试 fixture 挂 `level="WARNING"`,ERROR 与 WARNING 同池,删掉那条 error 用例照样绿。补 `captured_logs` fixture(连级别一起捕获)后三处补上级别断言 | §3.2 表、§3.3 表、§5 单元层 |
| 5 | **`acquire` 传的是完整预算,不是剩余预算**(独立验证发现,改文档不改代码): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍。行为无害,实测总耗时正好等于预算 | §3.1 |
@@ -0,0 +1,240 @@
---
type: design
node_id: design:2026-08-25-thinking-observability-design
title: "推理可观测性一等化(issue #16 + #17)"
date: 2026-08-25
---
# 推理可观测性一等化(issue #16 + #17
> 类型:design|日期:2026-08-25|状态:待人类确认
> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。
> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。
## 1. 问题不是 issue 说的那个
issue #16/#17Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。
**库自己握着决定性证据却没用它**`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。
所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。
## 2. 根因三层
| # | 缺陷 | 只修外层会留下什么 |
|---|---|---|
| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 |
| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 |
| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** |
第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。
## 3. 设计主张
一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。**
三条纪律贯穿全文:
- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。
- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。
- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。
## 4. 数据模型
### 4.1 `ThinkingObservation` 三态(新增,响应侧)
```python
class ThinkingObservation(StrEnum):
OBSERVED = "observed" # 确证推理发生
ABSENT = "absent" # 确证未推理(正面证据)
UNKNOWN = "unknown" # 无任何信号,判不出来
```
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7import-linter 契约执法)。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块,契约当场判红。纯值类型归最内层、决策逻辑归上层,是本设计的分层落法。
`StrEnum` 而非裸 `str` 常量:取值域显式、可类型检查,且它是 `str` 子类,`dataclasses.asdict` + `json.dumps` 天然可序列化(缓存回放路径见 §6)。
裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序:
| 条件 | 结果 | 理由 |
|---|---|---|
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 |
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
| 其余(`None` | UNKNOWN | 无信号,不猜 |
**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**transport 收集 `reasoning_content` 时只判 truthy`openai_compat.py`),上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入,校验后使用(P5)。
映射到实测:
| 场景 | observation | 是否诚实 |
|---|---|---|
| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 |
| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) |
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**,见下 |
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |
**`UNKNOWN` 不具证伪力,不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`,这意味着库无法证明推理真的关掉了。对账(§5)能提供的保障只有一个方向:**若模型真的推理了,可观测路径会把结果翻成 `OBSERVED`,告警随之触发**——M3 流式正属此列(关闭档若失效,正文会冒出来)。而不可观测路径(M3 非流式)没有任何保障,这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径,不是全部路径**。
`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。
### 4.2 明确不做:不把"可观测性"写进能力表
诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool``observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。
同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。
## 5. 对账:声明 × 观测
在 transport 拿到结果处做一次比较,矛盾即 warning:
| 请求方向 | 观测 | 能力表 | 处置 |
|---|---|---|---|
| `enable_thinking=False` | OBSERVED | 已登记 `can_disable=True` | **warning**:能力表漂移——声明说可关闭,实测推理了。附 model 与 `evidence` 日期,指路 `register_capability` |
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**:关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `enable_thinking=True` | ABSENT | 任意 | **warning**:注入了开启参数,上游明确上报未推理 |
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**:推理参数已注入但本路径观测不到,无法确认是否生效;**若为非流式路径,推理内容可能已计费却不回传**(M3 实测 completion 53 vs 关闭档 3 |
| `False` | UNKNOWN | 任意 | 不表态——不能证伪(§4.1) |
| `None`(不干预) | 任意 | 任意 | 不表态——调用方没提要求,无从谈"违背" |
前两行必须分开:`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning,那是**事前猜测**;这里的对账是**事后实证**,两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
第四行是 issue #17 关切的"静默失效"的诚实版本:库不再默不作声,而是明说"我注入了,但我看不见结果"。M3 非流式每次都落这一档,故节流不可少。
**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。
**节流**per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。
这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。
## 6. 落点清单
**源码**
| 文件 | 变更 |
|---|---|
| `types.py` | 新增 `ThinkingObservation`(枚举归最内层,§4.1);`LLMResponse``thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
| `providers.py` | 收缩为纯注册表:`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider` |
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"(库外无第三方实现者),故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**,属 CLAUDE.md 强制人类确认档 |
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) |
| `middleware/retry.py` | 透传新字段 |
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段;三个 `emit_*` 各传一行;`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**,不新开调用点(§12 |
| **`middleware/cache.py`** | `_rehydrate``LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) |
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`INSERT 字段 24→25,物理列 25→26 |
| `telemetry/sqlite.py``telemetry/postgres.py` | 实现新参 |
| `client.py` | import 路径改指 `thinking.py` |
| `__init__.py` | 新增包根导出,见 §7 |
**测试**
`tests/unit/``test_types.py`(默认值为 UNKNOWN、位置构造兼容、枚举归属模块)、`test_ports.py`(端口签名冻结测试与 recorder 替身)、`test_openai_compat.py`(裁定四分支、优先级、对账三类告警、节流只喊一次)、`test_retry.py`(透传)、`test_telemetry.py`(列数/列序/组装)、`test_cache.py`(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、`test_package.py`(包根导出面,比照 `TelemetryStatus` 先例)、`test_providers.py`(拆分后的注册表);`tests/integration/test_postgres_telemetry.py`(新列 backfill 与 round-trip);`tests/e2e/test_thinking_live.py`(判据重建,§8)。
**文档**(发布清单第 1 步要求构建前改完)
`README.md` 的"必录 24 字段"→ 25**须用 `inspect.signature` 实测而非凭记忆**`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构(补 `thinking.py`);`research-wiki/schemas/llm-calls.md`(标题仍写"22 字段",已过期两轮,本次一并订正为 25);`research-wiki/index.md`(登记本 design 与 finding;`CHANGELOG.md`(断裂项置顶,§13)。
`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。
默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
## 7. 模块边界:为什么新建 `thinking.py`
现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。
| 模块 | 职责 | 内容 |
|---|---|---|
| `providers.py` | **provider 是什么** | `ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider` |
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError``observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1 |
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
**同时把公共符号提升到包根导出**`ThinkingCapability``ThinkingObservation``register_capability``get_capability``resolve_thinking``ThinkingUnsupportedError``__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。
破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。
## 8. e2e 判据重建
四条红用例的病根是判据盲区,不是被测行为。逐条重建:
| 用例 | 旧判据 | 新判据 |
|---|---|---|
| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 |
| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED****删除 `_ON_MIN_COMPLETION` 魔数退路** |
| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 |
| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400 |
| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 |
| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 |
删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。
**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。
**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。
同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。
## 9. 被否决的路
| 备选 | 否决原因 |
|---|---|
| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 |
| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) |
| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) |
| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 |
| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) |
| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 |
## 10. 非功能维度
**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。
**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。
**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`
**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。
**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。
## 11. 错误处理与测试策略
新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。
| 层 | 覆盖 |
|---|---|
| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;schema 列数与列序断言(既有测试自动抓);包根导出面 |
| 集成 | SQLite/PG 新列 backfill 与 round-trip(既有测试模式) |
| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 |
**先失败后通过的证据**`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。
## 12. 明确不做
**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方(`middleware/telemetry.py` 文件头即如此声明)。三个 `emit_*` 是三个语义不同的入口(逐次尝试 / 缓存命中 / 终态失败),各自组装参数是职责所在,不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。
**不改 M3 的 `can_disable`**2026-08-25 复测 `reasoning_effort=none` → prompt 194= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。
**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。
## 13. 版本号
本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。
曾建议 1.4.0,理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。
**人类 2026-08-25 决定:发 1.3.1。** 决定已记录,实施按此执行。既然版号不再承担预警职责,预警必须由 CHANGELOG 独立扛起:断裂项与改法置于本版条目**最前**,沿用 1.3.0"请先读这一条"的体例,不得只在中段一笔带过。
## 14. 验收标准
- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次
- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED`
- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿
- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`
- 端口 `record_llm_call` 25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿
- 缓存回放的 `thinking_observation``ThinkingObservation` 实例而非裸字符串
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
- README 的遥测字段数经 `inspect.signature` 实测更新为 25ARCHITECTURE §8 模块结构含 `thinking.py``schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
@@ -0,0 +1,234 @@
---
type: design
node_id: design:2026-08-26-issue18-pg-test-isolation
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
date: 2026-08-26
---
# issue #18:隔离靠**权限强制**,目标靠**显式声明**
> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**7 条写真表的用例**全迁**`public.llm_calls` 里那 11 行历史孤儿行**不清理**。
## 1. issue #18 的诊断只对了一半
issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效:
| 失效方向 | 表现 | 标 `slow` 之后 |
|---|---|---|
| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 |
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |
这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。
## 2. 根因三层
| 层 | 事实 | 后果 |
|---|---|---|
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 |
| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 |
| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 |
L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
## 3. 设计主张
1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。
## 4. 变更 A`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
### 4.1 语义:声明即目标,不是"声明后比对"
两种可能的实现要先分清:
| | 做法 | 结果 |
|---|---|---|
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
选定做法的实现落点只有一处——`_purge_postgres``to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。
三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。
### 4.2 参数与校验
| 规则 | 行为 | 理由 |
|---|---|---|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.``\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 |
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
| 无权限 | 后续 `COUNT``PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
### 4.3 目标白名单:为什么表名段不可变
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at``tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。
`--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。
### 4.4 未声明时的提示
`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行:
```
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
```
只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。
## 5. 变更 B:测试角色化——把安全网换成权限边界
### 5.1 模型
**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path``--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。
每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是:
- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
- `tmp``public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权
**必须换角色的原因**`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。
防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT``DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`
**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。
### 5.2 最坏情况从"事后观测"变成"确定性红灯"
| 情形 | 旧 | 新 |
|---|---|---|
| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** |
| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public` |
`_public_count` / `before_public` / 那条 `assert` 整体删除。
### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网
用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`
- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门)
- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆
这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。
## 6. 变更 C7 条用例迁出 `public`
| 用例 | 迁移后验的东西 |
|---|---|
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) |
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) |
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 |
### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个
| 职责 | 落点 | 处置 |
|---|---|---|
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 |
删除行隔离用途时调用点做**机械替换**(`_cid("c1")``"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。
### 6.2 顺带封掉一个仓库自己已记载的隐患
`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template``except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
## 7. 变更 D`conftest.py` 收敛 + lint 门
### 7.1 一个沙箱工厂取代七处样板
`tests/integration/conftest.py` 新增:
| fixture | 职责 |
|---|---|
| `pg_admin_dsn`session | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
| `pg_sandbox`function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |
三条硬约束(缺一条工厂就会自己变成污染源):
1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。
2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。
今天这套样板在两个文件里重复**七处**(`legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema``least_privilege_dsn``least_privilege_pre_tenant_dsn``production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。
### 7.2 机械化执法
`make lint` / `make check` 各加一步:
```
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
```
§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。
**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。
## 8. 明确不做
| 不做 | 理由 |
|---|---|
| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 |
| 建临时数据库(而非 schema | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 |
| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) |
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 |
| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 |
## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决)
| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|---|---|---|
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 |
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 |
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
## 10. 版本号与发布
**1.3.2**patch)。需在 CHANGELOG 里如实写明:`tools/``tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。
发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`
## 11. 验收标准
| # | 判据 | 验证方式 |
|---|---|---|
| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG |
| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 |
| 2 | 未给 `--table``--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 stdout",计划阶段核出该判据不可执行,就地更正) |
| 3 | 最坏情况(search_path 落到 public**删不掉任何行**且退出 2 | §5.3 新用例 |
| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
| 5 | `tests/``public.llm_calls` 零命中 | `make lint` |
| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 |
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
| 6c | `test_schema_has_frozen_columns_in_order``table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles``pgw_%` 残留 |
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
## 12. 审查留痕(Codex2026-08-26
报 6 项实质问题,**全部采纳**,其中两项为阻断级:
| # | 意见 | 处置 |
|---|---|---|
| 1 | **阻断**`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 |
| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" |
| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 |
| 4 | `test_schema_has_frozen_columns_in_order``information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 |
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 |
| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b |
**一处处置与建议不同**:Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
@@ -0,0 +1,355 @@
# 推理档位一等化设计(issue #20 及其一般形式)
- **日期**: 2026-09-04
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
- **影响面**: `SourceConfig`/`ChatRequest` 公共类型、`ProviderProfile`/`ThinkingCapability` 公共类型、`resolve_thinking`/`reconcile_thinking` 公共函数、缓存 key 公式(ARCH §7.5)、遥测端口(25 → 26 字段)、`.env`
- **人类拍板(2026-09-04)**: 作用域取「源级默认 + 请求级覆盖」;档位不支持时「默认报错、可显式开映射」;不可关闭时「报错并给可执行替代」
- **人类复核(2026-09-04,针对 Codex 异议)**: `effort_fallback` 的最近档映射**要实现**,不因当前无已知消费者而推迟;方案选择标准 = 架构可维护性/清晰度 > 代码简洁 > 鲁棒性
---
## 1. 问题不是 issue #20 说的那个
issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解决它自己描述的失败,因为二态 bool 在新一代模型上无档可填。
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models``reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议:
| # | 结论 | 证据 |
|---|---|---|
| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 |
| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 |
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 |
第 1 条意味着 issue #20 建议的 `can_disable=True` 不能登记:我们发出去的 `reasoning_effort:"none"` 是个**未定义值**,智谱按自己的方式处理(多半当最低档)。这正好解释 issue #20 自己观测到的「短提示词 rt≈1.2,5552 token 长上下文跳到 0/54/167」——低档本来就要想,只是短提示词下想得少。
第 2 条意味着现有 `minimax` profile 那条「`thinking_on` 统一取 medium」的约定,推广到 GLM/kimi/deepseek 上全部是空档。
**真实缺口**: `enable_thinking: bool | None` 这个类型表达不了现实。补数据不能修复类型。
## 2. 现状审计(旧行为逐条处置)
替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明:
| # | 现有行为 | 处置 |
|---|---|---|
| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) |
| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) |
| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) |
| 4 | `evidence: str` 强制附实测出处 | **保留**,且强化: 初始表全部标注「文档推定,待实测」 |
| 5 | `resolve_thinking` 四道关卡(不表态/形态未知/能力未登记/不可关闭) | **保留四关的顺序与语义**,判据从 bool 换成档位(§4.1) |
| 6 | 能力未登记 → warning 后尽力注入 | **保留**(新模型不该被库挡住,ARCH §5 R4) |
| 7 | `observe_thinking` 多信号裁定三态 | **保留**,不改一行 |
| 8 | `reconcile_thinking` 声明 × 观测对账,矛盾返回文案、不抛错 | **保留**,判据扩展到档位(§4.3) |
| 9 | transport 按 `(source, model, direction)` 节流告警 | **替换**: 节流键的 `direction` 换成生效档位——同一模型 low 与 max 是两个独立的矛盾 |
| 10 | `ThinkingUnsupportedError(ValueError)`,由 transport 翻译为 `RequestRejectedError` | **保留**,新增的档位错误走同一条路 |
| 11 | `_build_payload``resolve_thinking` 结果先于 `extra_body`/`overlay` | **保留**(顺序即优先级,issue #4 决策 A) |
| 12 | 缓存 key 不含任何推理参数 | **修复**(§5,现存缺口) |
| 13 | 遥测无档位列 | **新增**一列(§6) |
**有意放弃**: 无。第 3 条的 `can_disable` 是唯一的破坏性变更,迁移见 §12。
## 3. 数据模型
### 3.1 档位词汇
八档封闭枚举,取四家参考实现共同收敛的词汇(cherry / OpenRouter / LiteLLM / new-api 用的是同一套):
```python
class Effort(StrEnum):
NONE = "none"; AUTO = "auto" # 不推理 / 推理但档位由模型自定
MINIMAL = "minimal"; LOW = "low"; MEDIUM = "medium"
HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
```
`none` 即「不推理」,与强度档同处一个词汇表——这是关键的表达力来源: 「能不能关」不再是独立的布尔,而是 `none` 在不在该模型的支持列表里。
`auto` 不可省(自审补): newapi 上 26 个模型里有 9 个是**纯开关型**(qwen 五个、MiniMax-M3、glm-5/5.1/4.6v),它们能开推理但没有档位名可填。没有 `auto` 就只能拿某个强度档冒充「开」,而那正是现有 `thinking_on` 硬编码 `medium` 的病根。`auto` 的 wire = `on_base` 不附 `effort_key`,恰好等于旧的 `thinking_on` 行为。四家参考实现都有这一档(cherry 的 canonical selection `'default'|'none'|'auto'|Effort`;new-api 的 `ModeAdaptive`)。
`Effort``types.py`(最内层纯值类型),与 `ThinkingObservation` 同处一处,理由相同: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import。
### 3.2 `ThinkingCapability`: 能力(按 model)
```python
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...] # 顺序 = 由弱到强
evidence: str
```
**不设 `default_effort` 字段**(Codex 审查采纳): 初稿有此字段,唯一消费者是「`enable_thinking=True` 等价于哪档」;自审把该语法糖改成 `Effort.AUTO` 后它就没有消费方了——§4 不用它决策,§6 遥测在不表态时记 `NULL`(库并不观测模型内部默认档,记推定值等于把「没看见」说成「发生了」,违既有纪律)。厂商默认档是**文档知识**,写进 `evidence` 文本即可,不必升格为必须逐模型维护的 API 字段(P1 YAGNI)。
三个派生量,不单独存字段(存了就会漂移):
| 派生 | 定义 | 用途 |
|---|---|---|
| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 |
| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) |
| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) |
OpenRouter 与 LiteLLM 两家**独立收敛到了同一形状**(`supported_efforts`+`default_effort` / `reasoning_effort_levels`+`default_reasoning_effort`),这是「档位清单即能力」这一形状可靠的旁证。我们只取其前半——两家都是**面向展示**的目录(要在 UI 上显示默认档),本库是**执行**路径,默认档不参与任何判定,故不设该字段。
### 3.3 `ProviderProfile`: 形态(按 provider)
```python
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None # 关闭档的片段;None = 该 provider 无关闭形态
on_base: Mapping[str, Any] | None # 开启档的固定部分;None = 形态未知
effort_key: str | None # 档位写进哪个键;None = 该 provider 无档位概念
```
`ProviderProfile.thinking_on/thinking_off``thinking: ThinkingWire` 取代。`None` 表示「未知」这一语义原样保留(issue #5 的核心成果,不可退回)。
四个形态样例(经 new-api 中转的口径):
| provider | off | on_base | effort_key |
|---|---|---|---|
| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` |
| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) |
| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
| minimax | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
### 3.4 为什么不需要 cherry 的 endpoint contract 与 wireDialect
cherry 有两层我们**明确不做**:
1. **endpoint-keyed 的 per-model wire 覆盖**。它需要这层,是因为同一模型在 `openai-chat` / `openai-responses` / `anthropic-messages` / `google-generate-content` 四种协议下形态不同。**本库只有一个 chat transport(`openai_compat.py`)**,所有请求都是 OpenAI 兼容形态,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent,见 `relaykit/relayconvert/reasoning/intent.go`)。一个协议 = 一层形态。
2. **`wireDialect` 代际方言**(Claude 4.6+ `adaptive` vs ≤4.5 `budget_tokens`;Gemini 3 `thinkingLevel` vs 2.x `thinkingBudget`)。这是**原生协议**才有的问题;我们发 OpenAI 形态的 `reasoning_effort`,代际差异由网关吸收。
同理,`glm-5.2`(有 `none` 档)与 `glm-5.3`(无 `none` 档)**共用同一份 wire**——差别落在 capability 的 `supported_efforts` 上。本库既有的「形态按 provider、能力按 model」分层,恰好容纳档位而无需新增一层。
## 4. 解析
### 4.1 `resolve_thinking`: 五道关卡
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 |
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** |
| 4 | 请求 `none` 而该模型无 `none` 档 | `ThinkingUnsupportedError`,**给出 `cheapest_effort` 作为替代** |
| 5 | 其余档位不在 `supported_efforts` 且未开映射 | `ThinkingUnsupportedError`,列出该模型可选档 |
**4 必须先于 5**(自审补): `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就退化成「不支持 none,可选 low/high/max」——丢掉了「这个模型根本关不掉」这个关键信息与可执行替代。
Phase 4 的文案是本设计的一个交付物,而非装饰:
> 模型 'glm-5.3' 无法关闭推理(官方 `thinking.type` 只接受 enabled);最省的档是 'low',请配 `LLM__ZHIPU__1__REASONING_EFFORT=low` 或调用时传 `reasoning_effort=Effort.LOW`。evidence: ...
理由: 该分支若只报错不给出路,下游会去找 `extra_body` 那条绕过的路——**那正是 issue #20 的成因**。报错必须带可执行替代,否则等于把用户推回起点。
映射(Phase 5 的逃生口)默认关闭,由 `SourceConfig.effort_fallback="nearest"` 显式开启,按 `supported_efforts` 的顺序取最近档并 warning。
> **Codex 审查异议与人类复核**: Codex 指出本项当前无可复验的消费者,引入它要带来配置项、映射算法、warning 口径与测试面。人类 2026-09-04 复核后**确认实现**——理由是这条逃生口的价值不取决于今天有没有人用它:换模型是常态,而「换完就跑不起来」与「换完静默涨价」之间需要一个下游可以显式选择的中间档。故本项**随本期一并实现**,含映射方向、warning 口径与测试。默认关闭的理由是钱: 一次静默的 `medium→max` 在 GLM-5.3 上是数倍账单,「严禁默认值掩盖错误」(P5)在此有真金白银的含义。
### 4.2 生效档位的优先级
```
request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语法糖) > None
```
`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
| 旧写法 | 等价于 |
|---|---|
| `enable_thinking=False` | `reasoning_effort=Effort.NONE` |
| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 |
**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|---|---|---|---|
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** |
| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** |
| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** |
第一行是**有意的**: 旧版那个 `medium` 是库替下游做的档位判断(profile 注释自己承认「取 medium 是因为它是五档里语义最接近厂商正常强度的一档」),而 `medium` 在 GLM/kimi/deepseek 的档位表里根本不存在——正是本设计要消灭的东西。语义仍是「开」(这三家的模型经 OpenRouter 登记默认即推理),只是不再强制一个档;要指定强度请显式配 `REASONING_EFFORT`。须进 CHANGELOG 的行为变更条目。
**第三行是 2026-09-05 的回退(issue #21,人类拍板的最小修复)**: 上述「语义仍是开」依赖「模型默认就推理」这个前提,T10 真实网关实测证明 MiniMax-M3 不满足它——不发任何推理参数时 5/5 轮不推理。故 `minimax` 段的 `on_base` 改回 `{"reasoning_effort": "medium"}`,存量 `ENABLE_THINKING=true` 的行为逐字恢复。这是权宜之计: 正解是让 `auto` 受能力表约束(模型不支持「由模型自定」时报错并指路显式档位),属公共行为变更,下一版处理。
**同源同时配 `enable_thinking``reasoning_effort` 且语义矛盾**(如 `True` + `none`)→ **构造期 `ValueError`**。不做「后者赢」的静默兜底: 两个字段表达同一件事时,矛盾是配置错误,不是优先级问题。
### 4.3 `reconcile_thinking`: 对账扩展
现有对账只判「要求关闭却观测到推理」与「要求开启却未推理」。档位化后新增一类可判定的矛盾:
- 请求 `none`、模型登记 `can_disable=True`、却观测到 `OBSERVED` → 既有文案,**保留**(这正是 issue #20 第 3 条要恢复的机制)。
- 请求非 `none` 档、观测到 `ABSENT` → 既有文案,保留。
- **不做**「档位高低与 `reasoning_tokens` 多少的对账」: 档位与 token 数没有可判定的函数关系(issue #20 自己的数据里 glm-5.3-flash 的 medium 档 rt 在 8~56 之间跳),拿它报警必然是噪声。这条留给 §11 的压测,不进库。
### 4.4 归一化不变式(实现期补,2026-09-05)
库内一切档位判定都是 `is Effort.X` 的身份比较,故**每条能让档位进入库内的入口都必须先归一**
(`types.coerce_effort`)。裸字符串不归一的后果不是报错而是**静默判否**——`("none" is Effort.NONE)`
恒假,于是「已关闭」被当成「没表态」。
已知三条入口,缺一即漏:
| # | 入口 | 归一点 |
|---|---|---|
| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` |
| 2 | 构造函数全量注入 `SourceConfig(...)``chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 |
| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` |
| 4 | **公共函数 `resolve_thinking()` 直调** | 函数入口自行 `coerce_effort`(2026-09-05 独立验证查出) |
第 3 条是 T8 加 `applied_effort` 字段时才浮现的: 响应进 Redis 走 JSON,`StrEnum` 存成裸串,
命中回放时类型已丢。与 `thinking_observation` 当年的坑**逐字相同**(见 issue #16/#17),故按同一
先例处置: 域外取值降级为 `None` 且**不作废整条缓存**——多项目共用 Redis 时互相打缓存是老问题,
为一个可观测字段丢掉整条响应不划算。
第 4 条是本次换代**自己造出来的**: 该函数在 `__all__` 里,第三参数由 `bool` 换成 `Effort` 后,下游最自然的写法就是从 JSON/配置读出来的裸串 `"low"`。不归一则 `_inject``.value``AttributeError`——一个未文档化、不属四分类的异常。
新增第五条入口时(新工厂、新 transport 参数、新的反序列化路径)必须同样过 `coerce_effort`
## 5. 缓存 key
**更正一个误判(Codex 审查指出)**: 源级 `extra_body``enable_thinking` **早已进 key**——经 `build_model_fingerprint``_fingerprint_mark`(`client.py`),由 issue #4/#5 落地,ARCH §7.5 有明文。本设计**不存在**先前稿本断言的「现存毒化缺口」,那是把 `CacheMW` 只读 `request.sampling` 误当成了全部 key 来源。
真正需要处置的是两处,均因请求级档位而新增:
| 层 | 处置 | 理由 |
|---|---|---|
| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
**已知取舍原样延续**: ARCH §7.5 已记载 `model_fingerprint` 是**集合级**而非本次选中源的指纹,同 scope 各源配置不同时仍可能返回另一源的响应;要求逐源可复现应让每源独享 scope 或 namespace。加入 `reasoning_effort` 后该取舍不变,本设计不扩大战线去改它。
**冷启动代价**: 只有新配 `REASONING_EFFORT` 的源冷启动一次;存量只配 `ENABLE_THINKING` 的源字面量逐字不变。
**key 用请求档,不用 `nearest` 映射后的生效档**(T6 实现时定,理由在此补正): 决定性的原因是
`CacheMW` 位于洋葱中比 transport 更外的一层,查缓存时 `resolve_thinking` 尚未执行,生效档
**根本拿不到**。副作用是被映射到同一档的两个请求(`minimal``low` 都映射到 `low`)各占一个
缓存槽,存两份相同响应——浪费但不毒化,可接受。
**由此带来一条已知边界**(与 ARCH §7.5 既有两条并列,不在本设计处理): 能力表更新导致映射结果
变化时(如某模型新增 `minimal` 档),请求档 `minimal` 算出的 key 不变而实际发出的字节变了,
会命中按旧映射存下的响应。能力表版本不进 `model_fingerprint` 是既有取舍的延续(provider 表
与能力表都不在指纹里),要求严格隔离的调用方应换 `cache_namespace``cache_salt`
## 6. 遥测
`llm_calls` 新增一列 `reasoning_effort TEXT`(INSERT 字段 25 → 26,物理列 26 → 27;两套口径的区分见 `telemetry/schema.py` 模块 docstring)。
记的是**本次调用生效的档位**,不是配置值——`None`(不表态)与 `'low'` 必须能区分,故可空。
不加此列则你要做的压测「不同档位是不是真有用」在数据侧无法分组: 现在 25 列里没有任何一列能回答「这一行用的是哪档」。补列走既有的 `PGW_TELEMETRY_SCHEMA_MODE` 机制,两端 DDL 与 `COLUMNS` 同源(schema.py 是单一事实源)。
## 7. 备选方案对比
| | 方案 | 改动面 | 权衡 |
|---|---|---|---|
| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** |
| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py``Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap``budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** |
**推荐 B**。A 治不了 issue #20 描述的病;C 的两项额外能力(多协议 wire、token 预算)在本库当前没有消费者,等真出现 budget 型需求时,`ThinkingWire` 增一个 `budget_key` 字段即可增量抵达,不必现在就上解释器。
## 8. 初始能力表(全部标注「文档推定,待实测」)
来源: 官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉。**这是待验证的假设,不是结论**——LiteLLM 里同一个 kimi-k3 在 `moonshot/` 下是三档、在 `perplexity/` 下是六档,中转会改档位有第三方证据。人类已定:能力表数据以后统一经 new-api 实测。
**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置:
| 情形 | 处置 |
|---|---|
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 |
| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 |
第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash``claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|---|---|---|---|
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
| glm-5.2 | none, high, max | max | ✓ |
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) |
| kimi-for-coding | 未查到 | — | ? |
| deepseek-v4-pro / -flash / -flash-vision-exp | none, high, max | high | ✓ |
| gpt-5.4, gpt-5.5 | none, low, medium, high, xhigh | medium | ✓ |
| claude-opus-5, claude-sonnet-5 | low, medium, high, xhigh, max(+`none` 经网关转 `thinking` 关闭) | high | ✓ |
| claude-haiku-5 | 推定同上 | — | ? |
| gemini-3.1-pro | low, medium, high | 官说 high / OR 说 medium(**打架**) | ✗ |
| gemini-3-flash | low, medium, high | — | ? |
| MiniMax-M3 | none, auto | auto | ✓ |
| MiniMax-M2.5, M2.7 | auto(**仅此一档**) | auto | ✗ |
| glm-5, glm-5.1, glm-4.6v | none, auto | auto | ✓ |
| qwen-plus-latest, qwen3.5-flash, qwen3.6-plus, qwen3.7-max, qwen3.7-plus | none, auto | auto | ✓ |
三个 embedding 模型(text-embedding-v2/v4、qwen3-vl-embedding)无推理语义,不入表。
## 9. 非功能维度
| 维度 | 回答 |
|---|---|
| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 |
| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
| **幂等** | 纯函数,无副作用,同输入恒同输出。重复调用安全 |
| **持久化** | 两处一次性影响: ① 缓存 key 变化 → 已配推理参数的 namespace 冷启动一次;② 遥测补列 → 走既有 `PGW_TELEMETRY_SCHEMA_MODE`,补列语句与 DDL 同源。均无部分写入风险(补列是 DDL 原子操作,缓存 miss 不损坏数据) |
## 10. 错误处理与测试策略
**错误分类**: 全部落 `RequestRejectedError`(不重试、不换源、不计熔断)。理由: 档位不支持是确定性的配置/参数问题,重试与换源都不会让它变对。路径与既有一致——`thinking.py``ThinkingUnsupportedError(ValueError)`,transport 在请求期翻译。
装配期 vs 运行期: 源级配置(`SourceConfig.reasoning_effort`)在**构造期**校验并报错;请求级(`ChatRequest.reasoning_effort`)只能在**运行期**校验,落 `RequestRejectedError` 上抛。
**测试策略**(先失败后通过,每条对应一个行为):
| 层 | 用例 |
|---|---|
| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 |
| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
| integration | 遥测 `reasoning_effort` 列在两端(sqlite/pg)落值正确,不表态时为 NULL |
| e2e(`slow`) | 经 new-api 对 §8 表逐模型实测,校正 `supported_efforts`;标 `slow`(成败取决于外部服务当下状态) |
## 11. 明确不做
1. **档位与 `reasoning_tokens` 的运行期对账**(§4.3): 无可判定的函数关系,拿它报警是噪声。
2. **token 预算型控制**(`thinking_budget`/`budget_tokens`): qwen 系支持,但当前无下游需求;`ThinkingWire` 可增量加 `budget_key` 抵达。
3. **原生协议 wire 与代际方言**(§3.4): 本库只有一个 OpenAI 兼容 transport。
4. **档位对采样参数的联动**: DeepSeek 思考模式不支持 `temperature`/`top_p`,Moonshot kimi-k2.5+ 固定采样参数,传别的值 400。**本设计不代下游做参数裁剪**——这是模型的约束,应由 evidence 记录并让 400 如实抛出,库替下游删参数是「默认值掩盖错误」。记入能力表 evidence,不写进代码逻辑。
5. **压测本身**: 「不同档位是不是真有用」是 `harness-eval` 范畴,依赖本设计的遥测列,不属于本设计。
## 12. 迁移与兼容
**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`):
| # | 位置 | 变更 | 谁会断 |
|---|---|---|---|
| 1 | `ThinkingCapability` | 构造签名 `can_disable``supported_efforts` | 自建能力表的调用方 |
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 |
| 4 | `thinking.resolve_thinking()` | 第三参数换语义(`bool``Effort`),**返回类型由 `Mapping` 改为 `ThinkingResolution`** | 读侧代码一律断 |
| 5 | `providers.ProviderProfile` | `thinking_on`/`thinking_off``thinking: ThinkingWire` | 自建 profile 的调用方 |
五者都在包根导出面或端口面上。
**先更正**(Codex 审查指出): 初稿称「已核实 `reference/` 三项目无调用点,实际影响面为零」——**该结论不成立**。`reference/` 下当前**没有** GovDoc-SaaS / Video-Tree-TRM5 / CHSAnalyzer 三个目录(工作区实际只有本次调研克隆的四个开源项目),此前的 `grep` 因目录不存在而输出空,被误读成「无匹配」。
真实的库内调用点(可复验):
| 位置 | 用法 | 处置 |
|---|---|---|
| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 |
| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 |
| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 |
| `__init__.py` | 包根导出 `ThinkingCapability`/`register_capability` | 符号名不变,构造形态变 |
**兼容策略**: 保留 `can_disable` 为只读派生属性(`Effort.NONE in supported_efforts`),**读侧代码一律不改**;位置参数构造无法兼容,库内三处随实现同步修改。
**下游影响面: 推断而非核实**。三项目尚未迁移接入本库(M4 才做),`ThinkingCapability` 是 2026-08-02 才加入的库内表,下游调用它的可能性低——但工作区读不到三项目源码,这条只能是推断。**须人类在审批时确认**,或在实现计划里加一步「三项目可读时复验调用点」。版本号取 **1.3.3**(2026-09-05 人类指令,不走 minor)。
**非破坏**: `SourceConfig.enable_thinking` 保留,行为等价(§4.2);`.env``ENABLE_THINKING` 键保留;新增键 `{SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT`。三项目不改配置即可继续跑,除非它们配的是「关闭一个官方不可关的模型」——那种情况**本来就是静默失效**,现在会明确报错并给出替代档。
## 13. 验收标准
1. 五道关卡各有先失败后通过的测试证据;Phase 5 文案内容被断言。
2. 同 messages 不同档位不再互相命中缓存。
3. 遥测能按档位分组(压测的前置条件)。
4. `.env` 只配 `ENABLE_THINKING` 的存量下游行为不变(回归测试)。
5. 进 `DEFAULT_CAPABILITIES` 的条目**仅限** §8 中无 `?`/无冲突者,每条 `evidence` 标注「文档推定,待实测」并附出处;其余条目留在设计文档里等实测,不登记。
6. import-linter 契约不破(`Effort``types.py`,不产生反向依赖)。
@@ -0,0 +1,62 @@
---
type: design
node_id: design:issue10-error-body-retention
title: "HTTP 错误响应体留存(Issue #10)"
date: 2026-08-16
---
# HTTP 错误响应体留存(Issue #10)
**来源**: Gitea issue #10(CHSAnalyzer3 现场,1050 张影像批处理中 1 张 400 被判确定性失败、事后无从查证)|**范围**: `errors.py` + 两个 transport|**全文**: `designs/2026-08-16-issue10-error-body-retention-design.md`|**相关**: [[design:issue8-stall-budget]](同为下游实测反馈驱动的治理修正)
## 问题
网关拒绝一次调用时,它说的话在 transport 翻译层被丢弃,进程中不再有任何副本:该模块无 logger、异常类无承载字段、库遥测只写 message。三条留存通道同时为空,故"永久查不到"。
## 根因(Issue 前提的关键修正)
Issue 建议"给异常加 `body_text` 字段,下游就能记进遥测"——**只做这一半解决不了它自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`retry.py:552``telemetry.py``sqlite.py``error TEXT` 列),即**异常 message**;新增字段不进库的遥测表。下游说的"写进遥测表"是他们自己的埋点。
缺陷范围也大于 issue 所述:实为 6 处同构——`_status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 两支(读了 body 判类型却不带),以及 `monkey_ocr._classify_status` 全部分支(message 只有 `HTTP {status}`)。Issue 场景"读表格"极可能正落在 OCR 路径。
## 选定方案
**摘要在翻译层算一次,同一份串同时进 message 与新增的基类字段**——前者解决"事后可查"(走既有遥测列,零 DDL),后者解决下游结构化留存。
| 决策 | 理由 |
|---|---|
| 字段加在 `PolyGatewayError` 基类,非 `RequestRejectedError` | 这些错误全由同一个 HTTP 响应翻译而来,"对方说了什么"与"属于哪一类"正交;只加子类,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 人类门 |
| 与 `ResultInvalidError.raw_text` 的界限写进 docstring | `body_text` = 非 2xx 的拒绝理由;`raw_text` = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用 |
| 新建 `transports/_http_errors.py` 共用摘要口径 | 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半" |
| `_status_to_error` 改表驱动 | 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短 |
| 摘要 = 折叠空白 + 总长 ≤ 2048,超出则**保留头 1400 + 尾 600**,中段记省略字数 | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 `maxUnstructuredResponseTextBytes`(唯一同场景先例);**头尾保留取自 `reprlib`**——JSON 错误体的 `code`/`request_id` 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废) |
| 429 不设例外 | 例外就是下一个复发点;`insufficient_quota` 那支的配额细节全在 body 里 |
| 400 治理语义不动,只补文档 | 见下 |
## 400 语义:不改行为,本次修复本身就是答复
Issue 给出有力证据(同字节 15 次重发全成功、`prompt_tokens=0`、2996ms 远低于同批成功最快的 7366ms),说明那次 400 来自中转服务抖动而非坏输入。**仍不改分类**:400 重试对直连供应商是纯浪费,而"中转也回 400"是部署拓扑引入的信息损失,库从状态码无从分辨;默认改可重试 = 让所有直连用户为一种部署形态买单。
但 body 留存后**下游能自己区分**——中转抖动体与供应商 `invalid_request_error` 体形态不同。库不替下游判断,把判断所需的信息交出去。配套在 docstring 与 ARCHITECTURE §6.2 加一句中转拓扑提醒。
## 被否决的备选
| 备选 | 否决理由 |
|---|---|
| 遥测端口加一列(22 → 23 字段) | 端口签名变更 + 双后端 DDL + 下游 ALTER + 列序契约全线改动,为一个诊断串付出跨三项目迁移;复用既有 `error` 列可达成同样可查证性 |
| 只打一条 WARNING 日志(issue 方向二) | 日志轮转后仍查不到,而痛点恰是"事后";且 4xx/5xx 在批处理下可能极高频 |
| 截断放进异常构造器 | 下游自建异常的文本被悄悄改写(违反 P4),且 message 侧仍需单独算一次,反出现两条规范化路径 |
| 错误分类映射可插拔 | Issue 场景确实指向它,但当前只有一个使用方且已用自己的兜底分类解决;`ProviderProfile` 无此扩展点,加它是子系统级设计。YAGNI |
## 有意不夹带(留独立 issue)
- `_status_to_error``operation` 硬编码 `"chat"`,而 `embed()` 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
- `_complete_stream``aread()` 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。
两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。
## 验收主张
一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。
**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。
@@ -0,0 +1,26 @@
---
type: design
node_id: design:issue11-caller-dimensions
title: "调用方自定义维度设计(issue #11)"
date: 2026-08-17
---
# 调用方自定义维度设计(issue #11)
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开,**已建 issue #12**。
- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
- **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
- **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN``IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
- **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
- **库止步于列 + policy 模板,绝不自动 ENABLE RLS**: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = **无声全局丢数据**——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 `CREATE POLICY`;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
- **哨兵值而非 NULL**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见且静默跳过**,故 NULL 的 `tenant_id` 不是"未归属"而是**对所有人永久不可见的黑洞**。用 `NOT NULL DEFAULT ''` 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
- **超限报错而非静默丢弃**: Langfuse 的"value 超 200 字符直接丢弃"**不抄**,违反 P5。报错点在 `chat()` 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 `overlay` 保护键先例)。
- **不进缓存 key**: `cache_namespace` 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
- **被否决备选**: 纯 `meta` JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 `cache_namespace` 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");`tenant_id` 混在 `meta` 里当约定 key(拼错不报错,静默降级成普通维度)。
- **审查留痕(Codex,2026-08-17)**: 报 4 项,逐条核实后**全部采纳**。① `embed()` 路径覆盖不足——`EmbeddingClient` 不走 chat 洋葱,`_emit()``embedding.py:360` 现场构造 `ChatRequest`,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② **非有限 float 会击穿"序列化不可达"论断**——`json.dumps``nan` 写成 `NaN` 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 `math.isfinite` + 序列化 `allow_nan=False` 双层收口;③ 校验入口表述只写 `chat()`,与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 `FORCE`/`USING`+`WITH CHECK`/`NULLIF(current_setting(...))` 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
- **另开议题(已建 issue #13)**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
@@ -0,0 +1,23 @@
---
type: design
node_id: design:issue12-telemetry-retention
title: "issue #12: 遥测表的正文体量、保留期与访问控制"
date: 2026-08-19
---
# issue #12: 遥测表的正文体量、保留期与访问控制
正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。
- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。
- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。
- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。
- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。
- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。
- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。
- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。
- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder``purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。
- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。
- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。
@@ -0,0 +1,21 @@
---
type: design
node_id: design:issue13-schema-mode
title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位"
date: 2026-08-19
---
# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。
- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。
- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。
- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。
- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。
- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。
- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。
- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。
- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。
- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。
@@ -0,0 +1,26 @@
---
type: design
node_id: design:issue15-telemetry-pool-lifecycle
title: "issue #15: 遥测连接池的资源语义与生命周期"
date: 2026-08-24
---
# issue #15: 遥测连接池的资源语义与生命周期
正文: `2026-08-24-issue15-telemetry-pool-lifecycle-design.md`。状态: **已实施(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)**——人类已确认方案、Codex 已审并逐条处置(正文 §9),T0–T7 全部完成;独立验证发现的 5 个问题已处置,实施期修订见正文 §10。前序: [[design:issue9-telemetry-ddl-probe]](判死判据的上一次收窄)、[[design:issue13-schema-mode]](schema 单一事实源)。
- **现象**: 共享 PG 实例余量紧张时,遥测**建池**失败 → `_failed` 永久置位 → 该 client 此后一行遥测都不落库,只有一条 warning,靠人肉对账才发现(19 次调用、成本少记约 $5)。
- **选定方案(四组一次做完)**: A 池语义(`min_size=0` + `max_size` 可配,缺省 4 + 整次写入硬预算 5s);B 失败三分(配置级致命 / 环境级不可用 / 行级拒绝)+ 60s 冷却降级取代永久判死;C 降级可见(共用 `TelemetryStatusTracker` + 节流复述 + 只读快照 `TelemetryStatus`,走**独立**端口 `TelemetryStatusProvider`);D 资源所有权纪律统一(谁建的谁关)。
- **地基是一条实测**: asyncpg `pool.py:457``if self._minsize:` ——`min_size=0` 时建池**零成本、不触库**(实测 0.000s、指向不可达端口照样成功)。这一步把"建池失败"从"混着瞬时错误的一刀切判死"变回真正的确定性失败,于是 issue 提的三个方向里,**方向 3(退避重试)大部分不必新建机制**(连接失败自动落到 `acquire`,那里本来就是"丢一行、池自恢复"的正确行为),**方向 2(共享池)从刚需降级为可选的显式能力**(闲时占 0)。
- **判据是主要交付物(两句,经审查补全)**: ①**致命 = 失败原因完全在进程内部且不可变**,其余一切失败都可能被外部修好,故一律带冷却重试;②**行级 vs 环境级看失败与这一行的数据有没有关系**——只与本行数据有关(换一行可能成功)= 行级,与数据无关、每行都会同样失败 = 环境级。按此,致命档窄到只剩"DSN 本身不可解析";认证失败、库不存在、表建不出来、权限被收、表被迁走一律归环境级(修好即自动恢复)。分类按 **PG SQLSTATE**(切到具体码,非前两位整类)而非 asyncpg 异常类白名单,不随驱动版本漂移。
- **`postgres.py:104-105` 的注释与代码不一致才是病灶**: 注释写"池建不出来 = 确定写不进去",这在 `min_size=10` 下是假的(连接耗尽只是这一秒写不进去)。冷却重试正面回应了该注释真正的顾虑("每次调用都内联吞一次 connect 超时"): 最坏成本变成"每 60s 一次、上界 5s"。
- **D 组是范围扩展,理由是同一根因的另外三个表现**: `GatewayClient.aclose` 关掉**注入的** telemetry(共享 recorder 被第一个关闭的 client 弄死,三处复制)、`RedisCache.aclose` 关掉注入的 redis 客户端、自建的 limiter/breaker redis 客户端**从来没人关**(泄漏)。不修它,ARCH §7.7 R5 的"共享必须显式注入"这条正道就一直是坏的——缺陷的放大器长在架构里,不在某个默认值里。纪律推广自库内已有的正确先例 `RedisLimiter._owns_client`
- **被否决备选**: 只调默认值(判据错位仍在,下次 PG 重启照样永久失能);只加建池退避(`min_size=0` 后建池已无可重试的失败);隐式全局池注册表(违反"无全局状态、无模块级单例"铁律);暴露 `min_size`(唯一作用是把脆点装回来);**遥测改异步队列 + 后台 flush**(真正彻底消除"遥测拖慢业务",但引入进程崩溃丢数窗口,与遥测作为**审计证据**的定位正面冲突,见 [[design:issue12-telemetry-retention]] 决策 E-a);把遥测失败塞进 `errors.py` 四分类(那套语义是"决定重试/换源/熔断",遥测不冒泡也不参与,塞进去污染分类)。
- **SQLite 侧有意只做一半**: 补可见性(今天初始化失败后写入连 warning 都没有),**不做** lazy 化与冷却。它的失败模式(本地目录不可写)在装配期就暴露,不是"跑到一半悄悄断",永久降级语义基本正确;tracker 与快照两侧共用,不产生第二套概念。与 [[design:issue9-telemetry-ddl-probe]] 的"两侧有意不对称"同一先例。
- **发布**: 版号发布时由人类定(semver 指向 1.3.0,但项目既有口径偏 patch: issue #11 扩端口列落 1.2.1、issue #14 设计写 1.3.0 实际发成 1.2.4)。两处需"请先读这一条"待遇: 遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);`aclose` 不再关闭注入的组件(修正越权,但依赖过"注入后由 client 代关"的下游会漏关)。
- **审查留痕(Codex,2026-08-24)**: 报 3 阻断 + 3 应改 + 1 可选,核实后 6 条采纳、1 条改判为实现约束。三条最重的都是同一类错误——**承诺比实现能给的更强**: ① "写入墙钟上界 = 一个预算"不成立,`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout(`pool.py:886-889, 930-937`),真实上界 ≈ 2 × 预算;② `Pool.close()` 等 in-flight 释放会**无限挂**,60s 只 warning(`pool.py:939-948, 961-972`),"关了就是关了"必须自己限时 + `terminate()`;③ 原稿"`TelemetryRecorder``health` 属性零成本"只覆盖静态类型,漏了它是 `@runtime_checkable`(`ports.py:246`)——加属性会让只实现 `record_llm_call` 的对象**当场不再满足协议**,库内 `tests/unit/test_ports.py:137,141` 的 isinstance 断言会红。
- **审查还逼出判据本身的自相矛盾**: 原稿只有"致命 = 进程内不可变"一句,却把 SQLSTATE `42` 整类归了行级——而 42501(权限被收)、42P01(表被迁走)恰恰是"能被外部修好"的。补出第二句判据(**行级 vs 环境级看失败与这一行的数据有没有关系**),两者改判环境级,`42703` 缺列成为唯一具名例外(它由 [[design:issue13-schema-mode]] 的"缺列须逐行暴露"承诺定死)。
- **自查另补两条 Codex 未发现的**: `health` 一词在 `ports.py` 已被占用两次(`check_health` 源探活、`health(source_name) -> float` 成功率 EWMA),故快照改名 `TelemetryStatus`(P2 领域术语);`asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一。
- **最低 Python 提到 3.12(人类决策,2026-08-24)**: 顺带消解了原 §6 那条取舍(`asyncio.timeout` 是 3.11 新增、3.11.0/3.11.1 的 `uncancel` 有缺陷),现在可直接用、不必退回 `wait_for`。代价有两项且**顺序不可颠倒**: conda 环境 `PolyGateway` 当前是 3.11.15,须先重建;ruff `target-version = "py312"` 立刻启用 UP047,`gather_bounded`/`_anext_within`/`stream_with_liveness_timeouts` 三处要改 PEP 695 语法,而该语法在 3.11 是 **SyntaxError**——只能在 3.12 环境就位之后改。版号因此确定 **1.3.0 起步**: 缩小支持面(3.11 下游 `pip install` 会被 pip 直接拒绝)比新增能力更该进 minor。
+16
View File
@@ -0,0 +1,16 @@
---
type: design
node_id: design:reasoning-effort
title: "推理档位一等化设计(issue #20 及其一般形式)"
date: 2026-09-05
---
# 推理档位一等化设计(issue #20 及其一般形式)
正文: `2026-09-04-reasoning-effort-design.md`。状态: **2026-09-04 人类已批准**
- **选定方案**: 方案 B「能力表档位化 + 源级/请求级双入口」。`Effort` 八档封闭枚举(含 `auto`)入 `types.py`;`ThinkingCapability``can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`(「能不能关」= `none` 在不在列表里);`ProviderProfile` 的两个固定片段改为 `ThinkingWire(off / on_base / effort_key)`;生效档位 = 请求级 > 源级 > `enable_thinking` 语法糖。
- **触发与真实缺口**: issue #20 字面要一条 zhipu profile,但补它不能解决它自己描述的失败——GLM-5.3 官方强制推理(智谱文档、cherry、OpenRouter 三源一致),`none` 是我们发出去的**未定义值**;而 `medium`(现 minimax profile 硬编码的档)在 GLM/kimi/deepseek 上根本不存在。缺口是**类型**表达不了现实,不是表里少一行。
- **人类三项拍板(2026-09-04)**: 作用域「源级默认 + 请求级覆盖」;档位打空时「默认报错 + 可显式开 nearest 映射」;不可关闭时「报错并给出该模型最低档作为可执行替代」。复核 Codex 异议后追加确认: `effort_fallback` 随本期实现,不因当前无消费者而推迟。
- **被否决备选及理由**: **方案 A 最小补丁**(只补 zhipu profile、维持 bool)——`thinking_on` 填什么档都是错的,`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」间二选一,治标;**方案 C 照抄 cherry 完整 wire DSL**(closed operations、`effortMap``budgetWire`、endpoint-keyed contract)——它需要那层是因为要支持四种端点协议,而本库只有一个 OpenAI 兼容 transport,跨协议转换由 new-api 服务端完成,该复杂度当前无消费者(P1 YAGNI);**`default_effort` 字段**——自审把 `enable_thinking=True` 的语法糖改成 `Effort.AUTO` 后失去唯一消费方,厂商默认档降为 `evidence` 文本;**档位与 `reasoning_tokens` 的运行期对账**——无可判定的函数关系(实测同档 rt 在 8~56 间跳),报警必成噪声;**代下游裁剪采样参数**(DeepSeek 思考模式不支持 `temperature`)——那是「默认值掩盖错误」,记入 evidence 而不写进逻辑。
- **审查留痕**: Claude 自审揪出两处实质缺陷(词汇缺 `auto`,导致 9 个纯开关型模型无档可填、等于把要修的 bug 重新实现一遍;五关顺序错置,使「关不掉」的特殊文案被通用分支吞掉)。Codex 独立审推翻两条**错误断言**: ① 源级 `extra_body`/`enable_thinking` **早已**经 `build_model_fingerprint` 进缓存 key(ARCH §7.5 有明文),不存在先前稿本断言的「现存毒化缺口」;② 「三项目无调用点」是对**不存在的目录**做 grep 得到的空结果,`reference/` 下当前并无三项目,迁移安全性只能是推断。另采纳其三条: 删 `default_effort`、补能力表落库规则、列出库内真实会断的调用点(`tests/unit/test_thinking.py:128` 的位置参数构造)。
+8 -1
View File
@@ -2,7 +2,12 @@
> **定位**: 用户文档站 = Gitea Wiki(`https://gitea.iomgaa.online/iomgaa/PolyGateway/wiki`);本文规定它的结构、更新时机与写作纪律。研发知识(设计/决策/验收)仍归 `research-wiki/`,两者职责不重叠。
## 1. 结构:Diátaxis 四区(2026-07-23 建站,17 页)
> [!CRITICAL]
> **现状(2026-08-02 起):文档站已全量下线,当前只剩 `Home` 一页占位。** 八轮审查累计确认 93 处与源码不一致,近半落在参考区(手工镜像源码里已有的事实,必然漂移),且修正本身在引入次生偏差,逐轮修补不收敛——过期文档比没有文档更危险,它看起来权威。
> Home 页现在做的唯一一件事是**把下游指向真实事实源**:签名/字段/参数语义 → 源码 docstring;全量环境变量键 → `.env.example`;版本变更与下游注意事项 → `CHANGELOG.md`;架构决策与行为论证 → `research-wiki/ARCHITECTURE.md`;快速上手 → `README.md`
> 历史内容未丢失,全在 wiki 仓库的 git 历史里(`git checkout e78bfb9 -- .`)。**本文以下各节描述的是重建时的目标结构与纪律,不是当前站点的现状**;在文档站重建之前,下面凡指向具体 wiki 页面的条目一律**不可执行**。
## 1. 结构:Diátaxis 四区(重建目标;2026-07-23 建站 17 页,2026-08-02 全量下线)
| 区 | 页面 | 职责(读者此刻要干什么) | 禁止 |
|---|---|---|---|
@@ -25,6 +30,8 @@
**门**: 版本 bump 的提交不允许单独存在——同一次交付里必须包含对应的 wiki/CHANGELOG 同步(发布检查清单第一项)。
**站点下线期间(2026-08-02 至文档站重建)本表如何执行**: 上表左列的判据照旧,右列中指向具体 wiki 页面的项**全部落空,不必也无法执行**;仍然必须做的是 `CHANGELOG.md``README.md``.env.example``research-wiki/ARCHITECTURE.md` 四处。这道门因此**没有放松**——只是承接方从 wiki 换成了这四个文件,漏改它们与从前漏改 wiki 是同一性质的失败。
## 3. 写作纪律
- 中文;表格优先;单个代码块 ≤ 15 行;每个配置片段可直接复制运行。
@@ -0,0 +1,94 @@
---
type: finding
node_id: finding:2026-08-25-thinking-observability-regression
title: "issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号"
date: 2026-08-25
---
# issue #16/#17 实测:M3 推理正常,失效的是推理的**可观测信号**
> 类型:finding|日期:2026-08-25|网关 `newapi.iomgaa.online`
> 本文推翻 issue #16/#17 的原始诊断("模型不再推理"),是 `designs/2026-08-25-thinking-observability-design.md` 的事实基础。
## 1. 为什么要重测
issue #16/#17 判定 MiniMax-M3 的开启推理"静默失效:模型没有推理",依据是 `tests/e2e/test_thinking_live.py` 的 L2/L3b/L4/L5 四条全红,四条的共同判据是 `reasoning_tokens > 0`。issue 自己留了一个未区分的岔路:网关侧模型行为变了,还是库的注入失效了。区分方法写得很清楚——抓一次真实请求体与原始响应。本文就是那次抓取。
## 2. 方法
两层探针,都不走 slow 套件:
其一**绕开库**,用裸 `httpx` 直接 POST `/chat/completions`,矩阵化七种参数形态 × 流式/非流式,记录完整 `usage``message` 的键集合。绕开库是必要的——要证的命题之一正是"库有没有把参数弄丢",用库测这一条是循环论证。
其二**用库本身**跑 `GatewayClient.chat`,记录 `LLMResponse``reasoning_tokens``thinking` 两个字段。两层对照才能定位缺口落在哪一层。
对照组取 `qwen3.7-plus``deepseek-v4-pro`——同一网关、同一 key,用来区分"MiniMax 这一路变了"与"网关全局变了"。
## 3. 原始观测
### 3.1 MiniMax-M3,裸 httpx,非流式
| 变体 | prompt | completion | `completion_tokens_details` | `reasoning_content` |
|---|---|---|---|---|
| 不注入(基线) | 194 | 3 | **整个容器缺失** | 无 |
| `reasoning_effort=medium` | **216** | **48** | 整个容器缺失 | 无 |
| `reasoning_effort=high` | **216** | **65** | 整个容器缺失 | 无 |
| `reasoning_effort=none` | 194 | 3 | 整个容器缺失 | 无 |
| `thinking={"type":"enabled"}` | 194 | 3 | 整个容器缺失 | 无 |
| `enable_thinking=true` | 194 | 3 | 整个容器缺失 | 无 |
| 非法值 `definitely-not-a-real-level` | 207 | 87 | 整个容器缺失 | 无 |
### 3.2 MiniMax-M3,裸 httpx,流式
| 变体 | delta 的键集合 | `reasoning_content` 累计 | usage |
|---|---|---|---|
| 不注入 | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
| `reasoning_effort=medium` | `content`,**`reasoning_content`**,`role` | **124 字符,完整推理过程** | prompt 216 / completion 60,无 ctd |
| `reasoning_effort=none` | `content`,`role` | 0 字符 | prompt 194 / completion 3,无 ctd |
流式 medium 档抓到的推理正文(前 120 字符):`We need answer Chinese, only two digits. Chickens x rabbits y. x+y=35,2x+4y=94 => x+y*? 2*35+2y=94 y=12, x=23. Output 23`
### 3.3 对照组(流式)
| 模型 | 变体 | `reasoning_content` | `completion_tokens_details.reasoning_tokens` |
|---|---|---|---|
| deepseek-v4-pro | 不注入 | 135 字符 | **88** |
| deepseek-v4-pro | `effort=medium` | 134 字符 | **89** |
| deepseek-v4-pro | `effort=none` | 0 | 容器缺失 |
| qwen3.7-plus | 不注入 | 350 字符 | **158** |
| qwen3.7-plus | `effort=medium` | 606 字符 | **229** |
| qwen3.7-plus | `effort=none` | 0 | 容器缺失 |
| qwen3.7-plus | 非法值 | — | **HTTP 400** |
### 3.4 用库跑(`LLMResponse` 字段)
| 场景 | `reasoning_tokens` | `thinking` 字符数 | completion |
|---|---|---|---|
| M3 开启,流式 | None | **185** | 69 |
| M3 开启,非流式 | None | **0** | 53 |
| M3 关闭,流式/非流式 | None | 0 | 3 |
| M3 不干预 | None | 0 | 3 |
| qwen 开启,流式 | **205** | 484 | 213 |
| qwen 关闭,流式 | None | 0 | 5 |
## 4. 五条结论
**① M3 的推理完全正常,issue 的诊断是错的。** 流式 medium 档抓到 124 字符完整推理过程;`prompt_tokens` 194→216(供应商注入推理指令)、`completion_tokens` 3→60(推理段被计费)。三个独立信号一致。
**② 真正变的是 MiniMax 这一路不再返回 `usage.completion_tokens_details`。** 而 qwen 与 deepseek 在同一网关同一 key 上照常返回。所以这不是网关全局改了 usage 处理,是 MiniMax 这一路上游的 usage 形态变了。`reasoning_tokens` 恒 NULL 由此而来。
**③ 库自己已经握有决定性证据,却没有用。** `LLMResponse.thinking` 在 M3 开启档流式路径下是 185 字符的实打实推理正文。e2e 的 `_reasoning_on` 只看 `reasoning_tokens``completion_tokens` 长度,从不看 `thinking`——四条红是判据的盲区,不是功能的失效。
**④ M3 非流式路径下推理内容整体丢失,且下游在付费。** `completion_tokens` 53 vs 关闭档 3,说明推理段确实产生并计费;而 `message` 的键集合只有 `content`/`role``reasoning_content` 不存在。下游用非流式调 M3 开推理 = 付钱买看不见的东西,且当前库不告诉它。这不是库能修的(上游不返回),但库必须让它可见。
**⑤ 三家供应商在"未推理"时都是整个 `completion_tokens_details` 缺失,无人上报 `0`。** 与 2026-08-02 findings §4c 的记录一致。推论:**"容器在不在"不能当作"有没有推理"的判据**——它与真实信号高度混淆,拿它做裁定等于把噪声当信号。
## 5. 顺带纠正的两处既有认识
**`enable_thinking` / `thinking:{type:enabled}` 对 M3 无效这一条仍然成立**(prompt 恒 194 = 基线),只有 `reasoning_effort` 是真开关。`providers.py` 的 minimax profile 用的正是 `reasoning_effort`,选型至今正确。
**L3b 的"非法值反证"手法只对不校验值的 provider 成立。** minimax 对非法 `reasoning_effort` 返回 200 且照常推理(prompt 207,介于基线 194 与 medium 216 之间,说明走了第三条模板路径);qwen 对同样的非法值直接 **HTTP 400**。这条手法写进测试时只在 minimax 上验过,它不可移植——若哪天把 L3b 套到别的 provider 上会得到假红。
## 6. `can_disable` 复测
M3 的 `ThinkingCapability(can_disable=True)` 的 evidence 停在 2026-08-02。2026-08-25 复测:`reasoning_effort=none` → prompt 194= 基线)、completion 3、无 `reasoning_content`。**声明依然成立**,只需刷新 evidence 日期并补记本文新发现的两条限制(非流式不可观测、仅 `reasoning_effort` 有效)。
@@ -0,0 +1,91 @@
---
type: finding
node_id: finding:2026-08-26-issue18-shared-pg-test-isolation
title: "issue #18 实测: 偶发红的是安全网本身,不是被测脚本"
date: 2026-08-26
---
# issue #18 实测:偶发红的是**安全网本身**,不是被测脚本
> 类型:finding|日期:2026-08-26|实例 `polygateway` 库(PostgreSQL 16.14,共享)
> 本文是 `designs/2026-08-26-issue18-pg-test-isolation-design.md` 的事实基础。
> 实测与推断在 §5 明确分界——推断部分未做复现实验,不当作既定事实使用。
## 1. 失败断言的唯一归属
`assert 12 == 61` 只能对应 `test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 的最后一行:
| 断言 | 形态 |
|---|---|
| `_call_ids(schema_dsn) == ["fresh-1", "fresh-2"]` | 列表比较,失败会打印列表 |
| `"将删除行数: 5" in result.stdout` 等五条 | 子串判定,失败不打印数字对 |
| `await _public_count(dsn) == before_public` | **整型比较,唯一能报出 `12 == 61`** |
`before_public` 在 seed 之前取,`12` 是脚本跑完后的复测值。
## 2. 被测脚本没有越界
失败发生在最后一条,意味着它前面全部通过:`_call_ids(schema_dsn)` 恰为 `["fresh-1","fresh-2"]`(临时 schema 里 5 行过期行被删、2 行新鲜行留下)、stdout 里出现 `<临时schema>.llm_calls``将删除行数: 5`、三条批次行齐全。
`search_path` 曾失效、脚本打到了 `public.llm_calls`,那么临时表 7 行一行不少,第二条断言就会先红。**故本次失败与 `telemetry_retention.py` 的行为无关**。
## 3. 共享表的实测现状
`.env``PGW_TELEMETRY_PG_DSN` 直连查得(2026-08-26):
| 项 | 实测值 |
|---|---|
| `public.llm_calls` 行数 | **11**,非分区普通表 |
| 这 11 行的 `created_at` | 全部落在 `2026-07-22 14:00 ~ 14:26` |
| 这 11 行的 `call_id` 形态 | 裸 hex 前缀(`3c915c04``c8071b6a` …)与一个 `c1`**不是** `pgwtest-` 前缀 |
| 表属主 / ACL | `app` / `{app=arwdDxt/app, chs3_test=ar/app}`(无 PUBLIC 授权) |
| `.env` 里那个角色 | `app``rolsuper = true``rolcreatedb = true``rolcreaterole = true` |
| 服务端版本 / 连接 | PostgreSQL 16.14`max_connections = 100`,查时 54 个连接在用 |
| 残留临时 schema / 角色 | 无(`pgw%` 命名下均为空) |
失败时的 `12` 与这个 `11` 行基线同量级;`61` 意味着取快照那一刻库里另有约 49 行,随后消失。那 11 行是一个多月前留下的**孤儿行**:它们早于 7 天截止线,任何一次带 `--apply` 的存量清理都会删掉它们——这本身说明真实共享表上确实存在"测试/工具写完没清干净"的历史。
## 4. 本仓库自己就是共享表的写入方
`tests/integration/test_postgres_telemetry.py` 存在两套并行的隔离手法:
| 手法 | 用在哪 | 是否触碰 `public.llm_calls` |
|---|---|---|
| 临时 schema`legacy_schema``fresh_schema``pre_tenant_schema``partitioned_schema``least_privilege_dsn``least_privilege_pre_tenant_dsn``production_template`) | 需要特定表形态的用例 | 否,teardown 走 `DROP SCHEMA CASCADE` |
| `_RUN_PREFIX` 前缀(模块级 `pgwtest-<uuid8>` | `TestObservabilityColumns::test_values_round_trip``TestSchema` 三条、`TestDegradation` 两条、`TestPoolFootprint` 一条,**共 7 条** | **是**,写入真表,`dsn` fixture teardown 执行 `DELETE ... WHERE call_id LIKE '<前缀>-%'` |
前缀隔离对**读**是完备的(每个进程只看自己的行),对**全表口径的观测**不设防——而 `_public_count` 正是全套件里唯一一处全表口径。
## 5. 实测与推断的分界
**实测(本会话工具输出)**:§1 的断言归属、§2 的失败顺序推理、§3 的全部数字、§4 的用例清单。
**推断(未做复现实验)**:那 49 行的来源。同一 pytest 进程内 `test_postgres_telemetry.py` 排在 `test_retention_tool_pg.py` 之前(文件名序),且其 `dsn` fixture 是函数级、每条用例后立即清理,故同进程解释不成立;最合理的解释是**另一个进程**在同一秒窗口内完成了一轮"写 7 条 → teardown 删掉"的循环——并行的另一个开发会话,或 `~/Projects/m4-worktrees/` 下迁移项目的批跑(三个迁移项目正是用本库往这张表写遥测)。
这条推断不影响结论:无论那 49 行由谁写删,`public.llm_calls` 的行数都是**不归本测试控制的全局可变量**,把它当断言基线在设计上就不成立。
## 6. 与 `_public_count` 的设计意图的落差
该断言的注释写明它要防的是"`search_path` 没生效导致静默删库"。行数快照防不住这件事:
- **假红**:任何外部写/删都让它红(本次即是),而脚本完全正常
- **假阴**:外部并发的增减可以与脚本的误删互相抵消,行数相等则静默放行——它守的是删库,这一半失效才是真正的代价
一个安全属性被编码成对全局可变量的观测,两个方向都不成立。
## 7. 方案可行性的实测(2026-08-26,同一实例)
用一次性角色/schema 做的证伪实验(建 `pgwprobe_r_*` 角色 + `pgwprobe_s_*` schema,跑完全部 `DROP`,实例上无残留):
| # | 探针 | 结果 |
|---|---|---|
| 1 | 角色以自己身份建表 | 属主为该角色(与"用维护角色跑"的现场一致) |
| 2 | `to_regclass('"<schema>"."llm_calls"')` | 正常解析到该表 |
| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** |
| 4 | `to_regclass('"<SCHEMA 大写>"."llm_calls"')` | **返回 NULL** —— 引号限定名区分大小写 |
| 5 | 临时角色**裸连**(不挂 search_path) | `SHOW search_path` = `"$user", public`,`to_regclass('llm_calls')` 命中真表 |
| 6 | 裸连对真表 `SELECT COUNT(*)` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
| 7 | 裸连对真表 `DELETE ... WHERE created_at < now()` | `InsufficientPrivilegeError: permission denied for table llm_calls` |
| 8 | 角色名与 schema **同名**时裸连 | `"$user"` 命中自有 schema,**遮蔽 public** |
第 6、7 条是新方案的核心防线:最坏情况下脚本连数都数不出来,更谈不上删。第 8 条是一条必须写进设计的约束——今天 `least_privilege_dsn` 的角色与 schema 恰好同名,若沿用该形态,"search_path 落到 public"的最坏情况用例会走到自有 schema 上,测出来的是个假现场。
+159 -1
View File
@@ -8,7 +8,7 @@
},
{
"id": "schema:llm-calls",
"label": "表结构: llm_calls(遥测 18 字段)",
"label": "表结构: llm_calls(遥测 25 字段)",
"type": "schema"
},
{
@@ -150,6 +150,66 @@
"id": "plan:issue8-stall-budget-plan",
"label": "issue #8 实施计划: stall 非生产性等待口径",
"type": "plan"
},
{
"id": "design:issue10-error-body-retention",
"label": "HTTP 错误响应体留存(Issue #10)",
"type": "design"
},
{
"id": "plan:issue10-error-body-retention-plan",
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
"type": "plan"
},
{
"id": "plan:issue11-caller-dimensions",
"label": "调用方自定义维度实现计划(issue #11)",
"type": "plan"
},
{
"id": "design:issue13-schema-mode",
"label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位",
"type": "design"
},
{
"id": "design:issue12-telemetry-retention",
"label": "issue #12: 遥测表的正文体量、保留期与访问控制",
"type": "design"
},
{
"id": "plan:plan-issue13-schema-mode",
"label": "实现计划: issue13-schema-mode",
"type": "plan"
},
{
"id": "plan:plan-issue12-telemetry-retention",
"label": "实现计划: issue12-telemetry-retention",
"type": "plan"
},
{
"id": "review:issue14-branch-review",
"label": "整分支审查: issue #14 熔断等待档",
"type": "review"
},
{
"id": "design:issue15-telemetry-pool-lifecycle",
"label": "issue #15: 遥测连接池的资源语义与生命周期",
"type": "design"
},
{
"id": "plan:plan-issue15-telemetry-pool-lifecycle",
"label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)",
"type": "plan"
},
{
"id": "design:reasoning-effort",
"label": "推理档位一等化设计(issue #20 及其一般形式)",
"type": "design"
},
{
"id": "plan:reasoning-effort",
"label": "实现计划: 推理档位一等化",
"type": "plan"
}
],
"links": [
@@ -271,6 +331,104 @@
"relation": "implements",
"evidence": "T1-T6 实施该设计,含 §3.6 订正",
"added": "2026-08-06T14:58:01.673693+00:00"
},
{
"source": "plan:issue10-error-body-retention-plan",
"target": "design:issue10-error-body-retention",
"relation": "implements",
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
"added": "2026-08-16T09:50:57.830855+00:00"
},
{
"source": "plan:issue11-caller-dimensions",
"target": "design:issue11-caller-dimensions",
"relation": "implements",
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
"added": "2026-08-17T10:09:08.967997+00:00"
},
{
"source": "plan:plan-issue13-schema-mode",
"target": "design:issue13-schema-mode",
"relation": "implements",
"evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md",
"added": "2026-08-19T13:10:55.616264+00:00"
},
{
"source": "plan:plan-issue12-telemetry-retention",
"target": "design:issue12-telemetry-retention",
"relation": "implements",
"evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md",
"added": "2026-08-19T13:10:57.986963+00:00"
},
{
"source": "plan:plan-issue14-admission-wait-policy",
"target": "design:2026-08-19-issue14-admission-wait-policy-design",
"relation": "implements",
"evidence": "research-wiki/plans/plan-issue14-admission-wait-policy.md;T0-T8 逐节映射设计 §3.1-§3.6",
"added": "2026-08-20T03:30:06.280582+00:00"
},
{
"source": "review:issue14-branch-review",
"target": "plan:plan-issue14-admission-wait-policy",
"relation": "informs",
"evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述",
"added": "2026-08-20T05:01:16.206639+00:00"
},
{
"source": "design:issue15-telemetry-pool-lifecycle",
"target": "design:issue9-telemetry-ddl-probe",
"relation": "refines",
"evidence": "把 issue #9 的'确定写不进去'判据从'哪一步失败'改为'失败是什么性质': 建池失败不再一律判死",
"added": "2026-08-24T05:50:56.787791+00:00"
},
{
"source": "design:issue15-telemetry-pool-lifecycle",
"target": "design:issue12-telemetry-retention",
"relation": "depends_on",
"evidence": "遥测作为审计证据的定位(决策 E-a)是否决'异步队列 + 后台 flush'备选的依据",
"added": "2026-08-24T05:50:57.954717+00:00"
},
{
"source": "plan:plan-issue15-telemetry-pool-lifecycle",
"target": "design:issue15-telemetry-pool-lifecycle",
"relation": "implements",
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
"added": "2026-08-24T12:05:46.300738+00:00"
},
{
"source": "plan:2026-08-25-thinking-observability-plan",
"target": "design:2026-08-25-thinking-observability-design",
"relation": "implements",
"evidence": "本计划 Task 1-10 实现该设计的全部落点与 §14 验收标准",
"added": "2026-08-26T04:49:16.312785+00:00"
},
{
"source": "finding:2026-08-25-thinking-observability-regression",
"target": "design:2026-08-25-thinking-observability-design",
"relation": "supports",
"evidence": "裸 httpx 与库两层实测(M3 推理正常、MiniMax 停报 completion_tokens_details)是该设计三层根因与三态裁定的事实基础",
"added": "2026-08-26T04:49:17.481308+00:00"
},
{
"source": "finding:2026-08-25-thinking-observability-regression",
"target": "design:2026-08-02-thinking-capability-design",
"relation": "refines",
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
"added": "2026-08-26T04:49:18.648857+00:00"
},
{
"source": "plan:2026-08-26-issue18-pg-test-isolation",
"target": "design:2026-08-26-issue18-pg-test-isolation",
"relation": "implements",
"evidence": "9 个任务逐条实现设计 §4-§11",
"added": "2026-08-26T11:28:33.469681+00:00"
},
{
"source": "plan:reasoning-effort",
"target": "design:reasoning-effort",
"relation": "implements",
"evidence": "10 个任务逐条覆盖设计 §3-§8;T10 兑现人类「能力表统一经 new-api 实测」的决定",
"added": "2026-09-05T04:07:17.723586+00:00"
}
]
}
+40 -5
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-08-07 15:11 UTC
> 自动生成,更新时间:2026-09-05 04:07 UTC
## design (26)
## design (41)
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
@@ -15,9 +15,21 @@
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-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-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-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 #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
@@ -26,16 +38,21 @@
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (12)
## finding (14)
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
@@ -44,7 +61,7 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (21)
## plan (36)
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -55,20 +72,38 @@
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
- [实现计划: 推理档位一等化](plans/reasoning-effort.md) `plan:reasoning-effort`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1)
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1)
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+53
View File
@@ -97,3 +97,56 @@
- [2026-08-07 15:20 UTC] 新增 design: 建表前先探测,判死只认「确定写不进去」 (design:issue9-telemetry-ddl-probe)
- [2026-08-07 15:20 UTC] 重建索引: 62 篇页面
- [2026-08-07 15:11 UTC] 重建索引: 62 篇页面
- [2026-08-16 09:09 UTC] 新增 design: HTTP 错误响应体留存(Issue #10) (design:issue10-error-body-retention)
- [2026-08-16 09:12 UTC] 重建索引: 64 篇页面
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode)
- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention)
- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode)
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention)
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention
- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面
- [2026-08-20 03:29 UTC] 新增 design: 熔断拒绝补齐等待档(issue #14) (design:issue14-admission-wait-policy)
- [2026-08-20 03:30 UTC] 新增 plan: 实现计划: 熔断拒绝补齐等待档(issue #14) (plan:issue14-admission-wait-policy)
- [2026-08-20 03:30 UTC] 新增边: plan:issue14-admission-wait-policy --implements--> design:issue14-admission-wait-policy
- [2026-08-20 03:30 UTC] 重建索引: 82 篇页面
- [2026-08-20 03:30 UTC] 重建索引: 80 篇页面
- [2026-08-20 05:01 UTC] 新增边: review:issue14-branch-review --informs--> plan:plan-issue14-admission-wait-policy
- [2026-08-20 05:01 UTC] 重建索引: 80 篇页面
- [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review)
- [2026-08-20 05:01 UTC] 重建索引: 81 篇页面
- [2026-08-24 05:49 UTC] 新增 design: issue #15: 遥测连接池的资源语义与生命周期 (design:issue15-telemetry-pool-lifecycle)
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --refines--> design:issue9-telemetry-ddl-probe
- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --depends_on--> design:issue12-telemetry-retention
- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面
- [2026-08-24 10:14 UTC] 重建索引: 83 篇页面
- [2026-08-24 10:15 UTC] design:issue15-telemetry-pool-lifecycle 经 Codex 审查: 3 阻断+3 应改+1 可选,核实后 6 采纳 1 改判,正文补 §9 审查留痕
- [2026-08-24 10:20 UTC] 最低 Python 提到 3.12(pyproject/ruff/README/CLAUDE.md 四处);design:issue15 §6 版本取舍消解,§7 补两项实施前置
- [2026-08-24 10:32 UTC] Python 3.12 迁移执行完毕: 环境重建 3.12.13、补装 build/twine、UP047 三处改 PEP 695;make check 绿、973 passed 覆盖率 94%
- [2026-08-24 12:05 UTC] 新增 plan: 实现计划: 遥测连接池的资源语义与生命周期(issue #15) (plan:plan-issue15-telemetry-pool-lifecycle)
- [2026-08-24 12:05 UTC] 新增边: plan:plan-issue15-telemetry-pool-lifecycle --implements--> design:issue15-telemetry-pool-lifecycle
- [2026-08-24 12:05 UTC] 重建索引: 85 篇页面
- [2026-08-24 12:10 UTC] plan:issue15 经 Codex 审: 2 阻断已修(20 处构造点须同批改、application_name 改走 DSN 查询参数)、_failed 计数修正
- [2026-08-24 15:48 UTC] issue15 T1-T7 实施完成: 池按需建连(min_size=0)+失败三分与 60s 冷却+TelemetryStatus 快照+所有权纪律统一,1038 passed
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正
- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面
- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面
- [2026-08-26 04:49 UTC] 新增边: plan:2026-08-25-thinking-observability-plan --implements--> design:2026-08-25-thinking-observability-design
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
- [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation
- [2026-08-26 11:28 UTC] 重建索引: 91 篇页面
- [2026-09-05 04:06 UTC] 新增 design: 推理档位一等化设计(issue #20 及其一般形式) (design:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增 plan: 实现计划: 推理档位一等化 (plan:reasoning-effort)
- [2026-09-05 04:07 UTC] 新增边: plan:reasoning-effort --implements--> design:reasoning-effort
- [2026-09-05 04:07 UTC] 重建索引: 95 篇页面
@@ -0,0 +1,259 @@
# 实现计划: HTTP 错误响应体留存(Issue #10)
- **设计**: `research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md`(**已批准 2026-08-16**)
- **分支**: `feat/issue-10-error-body-retention`
- **目标**: 网关拒绝一次调用时,它说的话必须能在库自己的遥测表里被事后查到。
- **方案概述**: transport 翻译层把 HTTP 错误响应体折叠空白并按头尾策略摘要,**同一份串**同时拼进异常 message(经既有 `error` 列落遥测)与新增的基类字段 `body_text`(供下游结构化留存)。覆盖两个 transport 的全部非 2xx 分支。不改任何状态码→分类的映射。
- **涉及技术**: Python 3.11 / httpx / pytest。无新增依赖。
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移——错误分类映射逐条不变,保真体现为"既有分类断言全部保留、无一条被改写"(Task 3/4 验收项)。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/errors.py` | 改 | 基类 `PolyGatewayError` 新增 `body_text` 字段;与 `raw_text` 的界限 docstring;`RequestRejectedError` 补中转拓扑提醒 |
| `src/polygateway/transports/_http_errors.py` | **新建** | 摘要口径单一实现:`summarize_body` / `compose_message` / `response_body` + 三个常量 |
| `src/polygateway/transports/openai_compat.py` | 改 | `_status_to_error` 表驱动重写;`_translate_429``ctx` |
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_classify_status` 带摘要 |
| `tests/unit/test_http_error_body.py` | **新建** | 摘要单元的纯函数用例(截断边界、头尾保留、折叠、幂等) |
| `tests/unit/test_openai_compat.py` | 改 | 状态码参数化断言 message + 字段;超长 `insufficient_quota` 回归 |
| `tests/unit/test_monkey_ocr.py` | 改 | OCR 分支同款 + `ResponseNotRead` 降级 |
| `tests/unit/test_errors.py` | 改 | `body_text` 默认值与可传性 |
| `tests/integration/test_governance_stack.py` | 改 | **端到端验收**:400 调用后 SQLite `error` 列含摘要 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` / `research-wiki/ARCHITECTURE.md` | 改 | 文档与 1.2.0 版本号 |
## 关键接口(跨任务消费,必须逐字一致)
```python
# src/polygateway/transports/_http_errors.py
from __future__ import annotations
import httpx # response_body 的类型与 ResponseNotRead 都来自它
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
def summarize_body(text: str) -> str:
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。"""
collapsed = " ".join(text.split())
if len(collapsed) <= _ERROR_BODY_CAP:
return collapsed
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
def compose_message(message: str, summary: str) -> str:
"""摘要非空才拼后缀,避免悬空分隔符。"""
return f"{message} | {summary}" if summary else message
def response_body(response: httpx.Response) -> str:
"""取已缓冲的响应文本;未读缓冲一律降级空串,绝不触发网络读。"""
try:
return response.text
except httpx.ResponseNotRead:
return ""
```
```python
# src/polygateway/errors.py
class PolyGatewayError(Exception):
def __init__(
self,
message: str,
*,
source_name: str | None = None,
status_code: int | None = None,
operation: str | None = None,
body_text: str = "",
) -> None:
```
```python
# src/polygateway/transports/openai_compat.py
# 既有 errors 导入(:18-23)须补入 PolyGatewayError —— 当前只导了四个子类,
# 直接写 _classify 的返回注解会让 ruff 报 F821 未定义名。
from polygateway.errors import (
PolyGatewayError, # ← 新增
RequestRejectedError,
ResultInvalidError,
SourceDeadError,
TransientError,
)
from polygateway.transports._http_errors import compose_message, summarize_body
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
"""状态码 → (错误类, message 标签);映射与 1.1.2 逐条相同。"""
if status in (401, 403):
return SourceDeadError, "凭据失效/欠费"
if status == 400:
return RequestRejectedError, "请求被拒"
if status >= 500:
return TransientError, "瞬时错误"
return RequestRejectedError, "客户端错误"
def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
) -> Exception:
summary = summarize_body(body_text) # 全函数只算一次
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"body_text": summary,
}
if status == 429:
return _translate_429(source, body_text, headers, ctx) # 传**原文**,见下
cls, label = _classify(status)
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
```
> **实现红线**:`_translate_429``insufficient_quota` 必须解析**未截断的原文** `body_text`,不得改用 `summary`。摘要会破坏 JSON 结构,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`——那是把一个诊断改进变成治理 bug。Task 3 有专门的回归用例钉死这条。
## 任务清单
### - [ ] Task 1: 内核新增 `body_text` 字段
**改**: `src/polygateway/errors.py`
- `PolyGatewayError.__init__` 按上文签名新增 `body_text: str = ""`,存为实例属性。
- 类 docstring 增补与 `ResultInvalidError.raw_text` 的界限:`body_text` = 非 2xx 的 HTTP 错误响应体摘要(对方拒绝的理由);`raw_text` = 2xx 但内容不可解析时的模型输出。并写明"可能包含请求回显,已截断"。
- **不动** `TransientError` / `SourceDeadError` / `RequestRejectedError` / `ResultInvalidError` / `GatewayUnavailableError` 的任何既有签名与行为。
**测试**(`tests/unit/test_errors.py`,扩展 `:29` 的四类构造形态参数化):
- 四个 transport 错误类默认 `body_text == ""`;显式传入后可读回。
- `GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError``body_text` 恒为 `""`(它们不经 HTTP 响应翻译)。
**验收**: 新增字段不改变任何既有异常的 `str()` 输出。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS。
### - [ ] Task 2: 共享摘要单元
**新建**: `src/polygateway/transports/_http_errors.py`(按上文"关键接口"逐字实现,**含其中的 `import httpx`**,加中文模块/函数 docstring 解释**为什么**折叠空白、为什么头尾保留、为什么 `response_body` 必须降级)
**新建测试**: `tests/unit/test_http_error_body.py`
| 用例 | 断言 |
|---|---|
| 短体原样 | `summarize_body('{"a":1}') == '{"a":1}'` |
| 空白折叠 | 多行缩进 JSON → 单行,无连续空格 |
| 空 / 纯空白入参 | 返回 `""` |
| 长度恰 2048 | 原样返回,无标记 |
| 长度 2049 | 走头尾策略 |
| 超长体头尾 | 前 1400 字符 == 原文前 1400;**末 600 字符 == 原文末 600**;中段标记内 N == `len(原文) - 2000` |
| **尾部关键字段可见**(设计 §7 用例 3c) | 以 issue 真实样本尾部 `"code":"invalid_parameter_error"}}` 收尾构造超长体 → 断言该串出现在摘要中 |
| 幂等 | `summarize_body(summarize_body(x)) == summarize_body(x)`(标记不嵌套) |
| `compose_message` | 摘要为空时返回原 message 不变;非空时以竖线分隔符拼接 |
| `response_body` 降级 | `httpx.Response(400, stream=<未读 SyncByteStream>)` → 返回 `""` 且不抛(构造法见下) |
未读响应的构造(已实测可用):
```python
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
resp = httpx.Response(400, stream=_Unread()) # 未 read → .text 抛 ResponseNotRead
```
**验收**: 摘要总长恒 ≤ `2000 + len(标记)`;头尾各自与原文逐字对应。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_http_error_body.py -v` → 全 PASS。
### - [ ] Task 3: openai_compat 翻译层收口
**改**: `src/polygateway/transports/openai_compat.py`
- 新增 `_classify`,`_status_to_error` 按上文骨架重写(五分支各拼各的 message → 查表 + 单点拼装)。
- `_translate_429` 签名改为 `(source, body_text, headers, ctx)`,两支 message 各自追加 `compose_message` 后缀,构造改用 `**ctx`;**`json.loads` 仍读原文 `body_text`**。
- message 主体逐字保持 1.1.2 原样(`凭据失效/欠费: {status}` / `请求被拒: 400` / `瞬时错误: {status}` / `客户端错误: {status}` / `配额耗尽(insufficient_quota)` / `限速: 429`),只在末尾追加 ` | {摘要}`
- 三个调用点(`:402` embed、`:417` stream、`:509` 非流式)签名不变,**不改动**。
- **补 import**:`PolyGatewayError`(errors)与 `compose_message` / `summarize_body`(`._http_errors`),见上文关键接口——漏补则 `make lint` 报 F821(Codex 审查 2026-08-16 提出)。
- **不改** `operation` 硬编码 `"chat"`(设计 §5.4 有意留给独立 issue)。
**测试**(`tests/unit/test_openai_compat.py`,沿用既有 `_transport_for(handler)` + `httpx.MockTransport`):
| # | 用例 | 断言 |
|---|---|---|
| 3.1 | 状态码参数化 400 / 401 / 403 / 404 / 500 / 503,handler 返回带真实样本体 | 异常类型与 1.1.2 **逐条相同**;message 含摘要;`exc.body_text` == 摘要 |
| 3.2 | 429 普通限速(body 无 `insufficient_quota`) | `TransientError`,message 含摘要,`retry_after_s` 解析不受影响 |
| 3.3 | 429 + `insufficient_quota` | `SourceDeadError`,message 含摘要 |
| 3.4 | **回归红线**:429 + `insufficient_quota` 且 body 长度 > 2048(前置大量填充字段) | 仍判 `SourceDeadError`——证明类型判定读的是原文而非摘要 |
| 3.5 | 空 body 的 400 | message 无悬空分隔符,`body_text == ""` |
| 3.6 | 非 JSON body、非 UTF-8 字节 body | 不抛额外异常,分类不变 |
| 3.7 | 流式路径(handler 对 stream 请求返回 400 + body) | 经 `_complete_stream:415-417` 抛出的异常同样带摘要 |
| 3.8 | embedding 路径(`transport.embed(...)` 遇 400) | 同样带摘要 |
**验收**: 既有测试零修改通过(除 3.x 新增外);`test_openai_compat.py:558`(match 源名)仍 PASS。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → 全 PASS。
### - [ ] Task 4: monkey_ocr 同款收口
**改**: `src/polygateway/transports/monkey_ocr.py`
- `_classify_status`:`summary = summarize_body(response_body(exc.response))`,三支 message 统一经 `compose_message` 追加后缀,`ctx``body_text=summary`
- message 主体保持 `f"{source_name} OCR {operation} HTTP {status}"` 不变。
- 429/5xx → `TransientError`、401/403 → `SourceDeadError`、其余 → `RequestRejectedError` 的映射**逐条不变**(OCR 无 429 细分是设计有意保留,见模块 docstring `:53-54`)。
**测试**(`tests/unit/test_monkey_ocr.py`):
- 扩展 `:300` 的状态码参数化:各分支 message 含摘要且 `body_text` 非空,分类不变。
- `ResponseNotRead` 降级:`exc.response` 为未读流 → `body_text == ""`,message 无悬空分隔符,**分类仍正确**(不得因取 body 失败而改变错误类型或抛出 httpx 异常)。
**验收**: `:195``:300` 既有断言不被改写。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_monkey_ocr.py -v` → 全 PASS。
### - [ ] Task 5: 端到端遥测验收(**本计划的硬判据**)
**改**: `tests/integration/test_governance_stack.py`
新增用例,沿用既有 `_full_client(handler, telemetry=SQLiteRecorder(...))``:135``SELECT error FROM llm_calls` 断言模式:
- handler 对 chat 请求返回 `httpx.Response(400, content=<issue #10 真实样本体>)`
- `client.chat(...)``RequestRejectedError`(400 不重试不换源,行为不变)。
- `recorder.close()` 后查 `SELECT error FROM llm_calls`:该行 `error` 串**含样本体里的 `InvalidParameter` 与结尾的 `invalid_parameter_error`**。
真实样本体(取自 issue #10 原文,一字不改):
```json
{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}
```
**验收**: 这条断言在 Task 1-4 之前**必然失败**(1.1.2 的 `error` 列只有 `"qwen_1 请求被拒: 400"`),之后通过——这就是本 issue 的"先失败后通过"证据主体,执行时须保留失败输出截图/文本进提交说明。
**验证**: `conda run -n PolyGateway pytest tests/integration/test_governance_stack.py -v` → 全 PASS。
### - [ ] Task 6: 文档与版本
**改**:
| 文件 | 内容 |
|---|---|
| `src/polygateway/errors.py` | `RequestRejectedError` docstring 加一句:经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类(设计 §5.2) |
| `research-wiki/ARCHITECTURE.md` §6.2 | 同一提醒 + 注明四分类错误自 1.2.0 起携带 `body_text` |
| `CHANGELOG.md` | 新增 `## 1.2.0(2026-08-16)` 段:行为变更(message 追加摘要 → 遥测 `error` 列变长)、新增字段、不变项(分类映射零变更、错误面零变更) |
| `README.md:34` | 安装 pin `==1.1.*`**`>=1.2,<2`**(2026-08-16 人类定夺;漏改则下游静默停在 1.1.2) |
| `pyproject.toml` + `src/polygateway/__init__.py` | 版本号 `1.1.2``1.2.0`,**两处必须一致** |
**验收**: `grep -rn "1\.1\.\*" README.md` 零命中;两处版本号一致。
**验证**: `conda run -n PolyGateway python -c "import polygateway; print(polygateway.__version__)"``1.2.0`
### - [ ] Task 7: 合并前全量门
1. `make lint`(ruff + import-linter)→ 零违规,**重点确认新建 `transports/_http_errors.py` 未触发洋葱分层契约**。
2. `make test` 全套件 → 全 PASS,覆盖率不低于既有水平。
3. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
4. `finishing-a-development-branch` 合并回 main(`--no-ff`),合并后在 main 上重跑 `make lint` 与全套件。
**发布**(合并后)严格按 CLAUDE.md §4.4.1 九步执行,不在本计划展开;其中步骤 1(更新 README)已在 Task 6 前置完成,**构建前须再次确认 pin 已是 `>=1.2,<2`**。
## 执行顺序与提交点
```
Task 1 ──┐
├── Task 3 ──┐
Task 2 ──┴── Task 4 ──┴── Task 5 ── Task 6 ── Task 7
```
Task 1 与 2 可并行(互不依赖);Task 3、4 都依赖 1+2;Task 5 依赖 3;Task 6 独立于代码但须在 Task 7 之前。每个 Task 一次语义化提交(`commit` skill),Task 5 的提交说明须附"修复前失败、修复后通过"的实际输出。
@@ -0,0 +1,269 @@
# 实现计划: 调用方自定义维度(issue #11)
- **目标**: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——`tenant_id` 为真实列(可挂 RLS),其余进 `meta` JSON 容器。
- **方案概述**: `llm_calls` 增两列(`tenant_id TEXT NOT NULL DEFAULT ''``meta` JSON);三个公共入口(`chat`/`embed`/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 `ValueError`;`TelemetryRecorder` 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
- **依据设计**: `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md`(已人类审批 2026-08-17)。
- **涉及技术**: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**
## 范围: 三条遥测链路(2026-08-17 人类追认)
设计初稿只覆盖 `chat()``embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit``ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。
**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | 新增 `validate_caller_dimensions()`;`ChatRequest` 增两字段 |
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 22 → 24 字段 |
| `src/polygateway/telemetry/sqlite.py` | 修改 | DDL 增两列、`_BACKFILL_COLUMNS` 增两项、`_COLUMNS` 增两项 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上(`_DDL`/`_BACKFILL`/`_COLUMNS`) |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 归一化并透传;三个 emit 入口从 request 读取 |
| `src/polygateway/client.py` | 修改 | `chat()` 增两参数并校验 |
| `src/polygateway/embedding.py` | 修改 | `embed()` 增两参数;沿 `_embed_batch`/`_attempt`/`_emit` 透传 |
| `src/polygateway/ocr.py` | 修改 | `recognize_text`/`parse_layout` 增两参数;沿 `_call`/`_attempt`/`_emit` 透传 |
| `tests/unit/test_types.py` | 修改 | 校验函数红线 |
| `tests/unit/test_telemetry.py` | 修改 | 三个 emit 入口带维度 |
| `tests/unit/test_client.py``test_embedding.py``test_ocr_client.py` | 修改 | 三条链路各自的端到端透传 |
| `tests/unit/test_ports.py` | 修改 | 端口 24 字段契约 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG 补列与写入 |
| `README.md``CHANGELOG.md`、Gitea wiki | 修改 | 能力表、RLS 模板、版本说明 |
**依赖顺序**: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。
---
## 关键接口(跨任务消费,此处定稿)
校验函数(`types.py`,紧邻 `validate_request_overlay` 放置,同款风格):
```python
def validate_caller_dimensions(
tenant_id: str | None,
meta: Mapping[str, Any] | None,
*,
origin: str,
) -> tuple[str | None, dict[str, Any]]:
"""校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""
```
`ChatRequest` 新字段(必须带默认值,ARCH §5.1 约定①):
```python
tenant_id: str | None = None
meta: Mapping[str, Any] = field(default_factory=dict)
```
`TelemetryRecorder.record_llm_call` 新增两个**无默认值**参数(`ports.py` 现有纪律),排在 `reasoning_tokens` 之后:
```python
tenant_id: str, # 已归一化: None → ''
meta: str, # 已序列化: 空 dict → '{}'
```
**归一化在 emitter 完成,不在 recorder**——与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。
---
## Task 1: 校验函数与请求字段
**文件**: `src/polygateway/types.py`(修改)、`tests/unit/test_types.py`(修改)
**行为**:
`validate_request_overlay` 之后新增 `validate_caller_dimensions()`,按 Phase 组织(照搬既有风格):
- Phase 1 `tenant_id`: `None` 直接放行;非 `str` 报错;**`tenant_id != tenant_id.strip()` 报错**(首尾空白一律拒绝,不是"strip 后为空才拒绝"——`" t1"``"t1"` 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);`strip()` 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
- Phase 2 `meta` 键形态: 非 `str` 报错;不匹配 `^[a-z0-9_.]{1,64}$` 报错;以 `pg_` 开头报错(保留前缀)。
- Phase 3 `meta` 键数量: > 16 报错。
- Phase 4 `meta` 值: 类型不属 `(str, int, float, bool)` 报错(注意 `bool``int` 子类,先判 `bool` 无妨,两者都合法);`float``not math.isfinite(v)` 报错;`str` 且长度 > 256 报错。
- 返回 `(tenant_id, dict(meta or {}))` —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 `overlay` 先例)。
`ChatRequest` 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;`meta` 不进缓存 key(`cache_namespace` 已负责租户隔离,ARCH §7.5)。
**`ChatRequest.meta` 必须保存校验函数返回的那个浅拷贝**,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 `overlay` 拷贝语义的理由)。
**验收标准**: 每条红线抛 `ValueError` 且消息含 `origin`;合法输入返回浅拷贝且与入参不是同一对象。
**测试要求**(先失败后通过):
逐条红线各一个用例——`tenant_id` 空串/纯空白/**`" t1"`/`"t1 "`(首尾空白)**/超长/非 str;`meta` 键非 str/含大写/含连字符/超 64 字符/`pg_` 前缀/17 个键;值为 `list`/`dict`/`None`/`nan`/`inf`/`-inf`/超 256 字符的 str。另加合法路径用例: `tenant_id=None` + `meta={}` 放行、`meta` 值为 `bool`/`int`/`float` 有限值放行、返回值是拷贝(改返回值不影响入参)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
**另需一条缓存隔离测试**(放 `tests/unit/test_cache.py``test_client.py`): 相同 `messages` + 相同 `cache_namespace`、**仅 `meta` 不同**的两次调用,第二次**仍应命中缓存**。设计明确 `meta` 不进缓存 key(`cache_namespace` 已负责租户隔离);没有这条测试,实现者顺手把 `meta` 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
- [ ] 提交点: `feat: validate the dimensions a caller may attach to a call`
---
## Task 2: 端口与两个遥测后端 schema
**文件**: `src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``tests/unit/test_ports.py`(均修改)
**行为**:
`ports.py`: `record_llm_call``tenant_id: str``meta: str`(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
`sqlite.py` 三处同步改(顺序必须一致):
- `_DDL``reasoning_tokens` 之后追加 `tenant_id TEXT NOT NULL DEFAULT ''``meta TEXT NOT NULL DEFAULT '{}'`;
- `_BACKFILL_COLUMNS` 追加 `("tenant_id", "TEXT NOT NULL DEFAULT ''")``("meta", "TEXT NOT NULL DEFAULT '{}'")` —— SQLite 硬性要求 `NOT NULL` 列必须带非 NULL 常量默认值,缺默认值会报 `Cannot add a NOT NULL column with default value NULL`;
- `_COLUMNS` 追加两项。
`postgres.py` 同三处:
- `_DDL` 追加 `tenant_id TEXT NOT NULL DEFAULT ''``meta JSONB NOT NULL DEFAULT '{}'::jsonb`;
- `_BACKFILL` 追加两条 `ALTER TABLE llm_calls ADD COLUMN ...`(默认值均为非易失常量,PG 11+ 不重写全表);
- `_COLUMNS` 追加两项。
**新列必须排在末尾**(`created_at` 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(`sqlite.py:53` 既有注释)。
**验收标准**: 两个后端的 `_COLUMNS` 逐字同名同序;新建库与旧表补列后列集合一致。
**测试要求**(先失败后通过): 扩展现有列序断言测试,断言两后端 `_COLUMNS` 相等且末两项为 `("tenant_id", "meta")`;`test_ports.py` 断言 `record_llm_call` 的参数集合含新两项且**无默认值**(用 `inspect.signature` 实测,不凭记忆)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS
- [ ] 提交点: `feat: give the telemetry table a tenant column and a meta container`
---
## Task 3: Emitter 透传与归一化
**文件**: `src/polygateway/middleware/telemetry.py`(修改)、`tests/unit/test_telemetry.py`(修改)
**行为**:
`_record` 增两个形参,**位置排在现有末参 `reasoning_tokens` 之后**(它是 keyword-only,顺序不影响调用,但与 `_COLUMNS`/端口的追加位置保持一致便于逐行比对):
```python
async def _record(
self, *, ...,
reasoning_tokens: int | None,
tenant_id: str | None, # 新增: 未归一化,None 合法
meta: Mapping[str, Any], # 新增: 未序列化,空 dict 合法
) -> None:
```
在传给 recorder 前归一化: `tenant_id or ''`;`json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)`,空 dict 直接用字面量 `'{}'`
`allow_nan=False` 是第二道闸(主防线是 Task 1 的入口校验)——`json.dumps` 默认把 `nan` 写成 `NaN` 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。
三个 emit 入口统一从 `request.tenant_id` / `request.meta` 读取,不各自组装(遥测调用点收敛铁律):
- `emit_attempt``emit_terminal_failure`: 直接读 `request`;
- `emit_cache_hit`: **同样读 `request` 而非缓存中的历史响应**——维度是"本次调用由谁发起",不是历史那次。
**验收标准**: 三条路径写出的行都带维度;`tenant_id=None``''`;`meta={}``'{}'` 而非 NULL。
**测试要求**(先失败后通过): 用 fake recorder 断言三个入口各自收到的 `tenant_id`/`meta` 值;`emit_cache_hit` 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 **A**(这是最容易实现反的一处);`meta` 序列化后键有序(`sort_keys=True`,便于跨行比对)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions through the single telemetry helper`
---
## Task 4: `chat()` 公共入口
**文件**: `src/polygateway/client.py`(修改)、`tests/unit/test_client.py`(修改)
**行为**: `chat()` 签名末尾增 `tenant_id: str | None = None``meta: Mapping[str, Any] | None = None`(带默认值的 keyword-only,签名冻结承诺不破)。在既有 `validate_request_overlay` 调用旁调 `validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)")`,把返回值填进 `ChatRequest`。校验必须在**进洋葱之前**——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。
**验收标准**: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 `chat()``ValueError` 且**未产生任何遥测行**。
**测试要求**(先失败后通过): 端到端——`chat(..., tenant_id="t1", meta={"batch": "b-42"})` 后 fake recorder 收到的行带这两个值;非法 `meta``ValueError` 且 recorder **零调用**(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 `''``'{}'`
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
- [ ] 提交点: `feat: let chat() take a tenant and caller-defined dimensions`
---
## Task 5: `embed()` 链路透传
**文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
**行为**: `embed()` 增两个 keyword-only 参数并在入口校验(`origin="embed(tenant_id=..., meta=...)"`),沿 `_embed_batch()``_attempt()``_emit()` **逐层透传**,在 `_emit()`(`embedding.py:360`)构造 `ChatRequest` 时填入。
该链路已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数。**不顺手把它们收成值对象**——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。
**验收标准**: 多批(`texts` 长度 > `batch_size`)时**每一批的行都带同一份维度**——维度属于本次 `embed()` 调用,不随批次变化。
**测试要求**(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的**每一行**都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 `ValueError` 且零遥测。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_embedding.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions down the embedding chain`
---
## Task 6: OCR 链路透传
**文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
**行为**: `recognize_text()``parse_layout()` 各增两个 keyword-only 参数并在入口校验(`origin` 分别标明方法名),沿 `_call()``_attempt()``_emit()` 透传,在 `_emit()`(`ocr.py:398`)构造 `ChatRequest` 时填入。结构与 Task 5 同构。
**验收标准**: 两个公共方法都覆盖(只改一个即漏)。
**测试要求**(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 `ValueError` 且零遥测。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions through the OCR chain`
---
## Task 7: 真实后端集成验收
**文件**: `tests/integration/test_postgres_telemetry.py`(修改)、`tests/unit/test_telemetry.py`(补 SQLite 真实文件用例)
**行为**: 覆盖三件事,每件两个后端各测一遍。
1. **新建库**: 表列齐全,写入后读回维度一致。
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
3. **补列失败的降级方向**: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。**两端的失败构造方式不同,不可笼统写"各测一遍"**:
- **Postgres**: 用只有 `SELECT, INSERT ON llm_calls` 权限的角色连接——`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 的存在性判断,故必然失败。断言: 记 warning、`_failed` **未**置位、后续 INSERT 仍尝试。
- **SQLite**: 无角色权限模型,等价构造是**文件只读**(`chmod 444` 或以 `file:...?mode=ro` 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 `self._conn`、不抛出 `__init__`"(即 `sqlite.py:112` 那条既有纪律),**不断言"写入仍成功"**——那在只读库上本就不可能。
**验收标准**: PG 与 SQLite 行为对称;补列走既有 `_BACKFILL`,不新增 DDL 路径。
**测试要求**(先失败后通过): 上述三项即测试本体,**同样适用红绿证据门**——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。
**验证命令**:
`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS
`conda run -n PolyGateway pytest tests/ -q` → 全套件 PASS(**命令末尾不接管道**,否则退出码失真)
- [ ] 提交点: `test: prove old telemetry tables gain the tenant column safely`
---
## Task 8: 文档同步
**文件**: `README.md``CHANGELOG.md`、Gitea wiki 的 `指南-遥测与成本``参考-公共API` 两页
`docs-convention.md` §2「新公共 API / 新能力」行,须同步「对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG」。本次**扩写** `指南-遥测与成本`(新增"多租户与自定义维度"一节)而非新建页,故**侧边栏与 `Home.md` 不动**——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 `指南-多租户`),则必须一并改 `_Sidebar.md``Home.md` 分流表。
**行为**:
- **CHANGELOG**: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、**以及库不建索引/不启用 RLS 的边界**。
- **README**: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 `inspect.signature` **实测**后再写(发布流程 §4.4.1 第 1 步的教训)。
- **`参考-公共API`**: 更新 `chat()``embed()`(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
- **`指南-遥测与成本`**: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(`ENABLE` + `FORCE` + `USING`/`WITH CHECK` 双写 + `NULLIF(current_setting(..., true), '')`)与复合索引 `(tenant_id, created_at)`,并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在**显式事务内**用 `set_config(..., true)`(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行。
- wiki 必须明确: **执行这些 DDL 是下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个普通列,没有数据库层强制。
**验收标准**: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。
**验证命令**: `conda run -n PolyGateway make lint` → PASS;人工核对 wiki 页面渲染。
- [ ] 提交点: `docs: document caller dimensions and the RLS template`
---
## 全局验收
- [ ] `conda run -n PolyGateway make lint` → PASS(含 import-linter 依赖契约)
- [ ] `conda run -n PolyGateway make test` → PASS,覆盖率不低于改动前
- [ ] 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
- [ ] 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
- [ ] 旧表补列后老行读出空串的证据(issue 核心论点的验收)
@@ -0,0 +1,180 @@
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**
- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451``_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter``text_cap` 必填 |
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings``telemetry_text_cap` |
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
| `tests/unit/test_config.py` | 修改 | 配置校验 |
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
| `README.md``CHANGELOG.md``.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
---
## 关键接口(跨任务消费,此处定稿)
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
```python
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
"""
```
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
```python
class TelemetryEmitter:
def __init__(
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
text_cap: int | None,
) -> None: ...
```
三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性):
```python
class GatewayClient: # client.py:130 起的构造签名
def __init__(self, *, ..., text_cap: int | None = None) -> None: ...
# EmbeddingClient / OcrClient 同款
```
**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
```python
telemetry_text_cap: int | None
```
`tools/telemetry_retention.py` 的 CLI 契约:
```text
--backend sqlite|postgres 必填
--path PATH | --dsn DSN 按 backend 二选一,必填
--older-than-days N 必填,N >= 0
--apply 缺省不带即 dry-run(只统计不删)
--batch-size N 仅 postgres,缺省 1000
--vacuum 仅 sqlite,须与 --apply 同时给
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
```
---
## Task 1: 正文截断与 emitter 参数
- [ ] **文件**: `src/polygateway/middleware/telemetry.py``src/polygateway/client.py``src/polygateway/embedding.py``src/polygateway/ocr.py`;`tests/unit/test_telemetry.py``tests/unit/test_cache.py`
- **行为**:
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text``thinking` 调用 `_cap_text`
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149``embedding.py:131``ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
- `embedding.py:73``ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
- **验收**:
- `cap=None` → 落库正文与今天逐字节相同。
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
- 非字符串 content(如 `123``None`、嵌套 dict)不抛异常。
- `response`/`thinking` 同样受 cap。
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
- **测试**:
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
- **提交**: `feat: cap telemetry bodies at a configurable length`
## Task 2: 配置与装配
- [ ] **文件**: `src/polygateway/config.py``.env.example`;`tests/unit/test_config.py`
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings``telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0``ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
- **验收**: 未设 → `None`;`"0"``"-1"``ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: wire the telemetry text cap through settings`
## Task 3: 保留期脚本
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`
- **行为**: 按上文 CLI 契约实现。
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
- **验收**: 见测试。
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
- dry-run 后行数不变,stdout 含将删行数与时间范围。
- `--apply` 后仅超期行被删,未超期行完好。
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
- `--vacuum` 不带 `--apply` 时退出码 1。
- **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。
- **提交**: `feat: add a retention script downstreams can schedule`
## Task 4: 生产部署模板与其机械化验收
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)``pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
- **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"``text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。
- **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
- `report` 角色能读、不能写。
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
- 分区表上写入成功且落进当月分区。
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
- **提交**: `docs: ship a production deployment template with its own test`
## Task 5: CHANGELOG 与 wiki
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
- **测试**: 无自动化测试。
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
- **提交**: `docs: record the retention boundary and its knobs`
---
## 完成判据
1. 五个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
@@ -0,0 +1,177 @@
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings``telemetry_auto_migrate` |
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
| `.env.example``README.md``CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
---
## 关键接口(跨任务消费,此处定稿)
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
```python
COLUMNS: tuple[str, ...] # 24 个 INSERT 字段(call_id 起、meta 止)
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
PG_DDL: str
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
PG_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
```
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS``created_at`,可作对照)。
**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。
两个语句构造函数:
```python
def insert_sql(backend: str, columns: Sequence[str]) -> str:
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`
"""
def telemetry_schema_sql(backend: str) -> str:
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
```
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
```python
class SQLiteRecorder:
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
class PostgresRecorder:
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
```
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
```python
telemetry_auto_migrate: bool
```
---
## Task 1: 建 `telemetry/schema.py` 单一事实源
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
- **行为**: 把 `sqlite.py``_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS``postgres.py``_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()``telemetry_schema_sql()`
- **验收**:
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`
- `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
- **提交**: `refactor: make the telemetry schema a single source of truth`
## Task 2: PG 写入去掉冲突目标
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12`created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
- **行为**:
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns``self._insert = insert_sql(backend, effective)`;`record_llm_call``self._columns` 取值。
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
- **验收**: 见测试。
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
- manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id``meta``ALTER TABLE`
- auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
- manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
- **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436``captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
- 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
## Task 4: 配置派生与装配
- [ ] **文件**: `src/polygateway/config.py``.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
- **行为**:
- `config.py``_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
- `GatewaySettings``telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`
- `.env.example``PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: derive the schema mode from the telemetry backend`
## Task 5: 公共导出
- [ ] **文件**: `src/polygateway/__init__.py``tests/unit/test_package.py`
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
## Task 6: 真实 Postgres 集成验收
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
- **验收**:
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
- **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
- **提交**: `test: prove manual mode leaves a stale table untouched`
## Task 7: 文档与承诺
- [ ] **文件**: `README.md``CHANGELOG.md``research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
- **行为**:
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
- **提交**: `docs: document the schema mode and the expand-contract promise`
---
## 完成判据
1. 七个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
@@ -0,0 +1,380 @@
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
- **设计**: `research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md`(已过 Codex 审 + 人类审)
- **涉及技术**: Python 3.12(PEP 695 已就位)、asyncpg 0.31 连接池、`asyncio.timeout`、PG SQLSTATE、frozen dataclass、`@runtime_checkable` Protocol、pytest(含真实 PG 的 integration)
- **版号**: 1.3.0(人类已定;**本计划不 bump 版本号**,那是发布清单第 3 步的事)
- **状态**: **已实施**(2026-08-24)。T0T7 全部提交完成,提交表见文末;合并前的三道门(`pytest -m slow`、独立 verifier、整分支审查)见「完成判据」)
## 目标
让遥测池的资源占用与真实负载挂钩,把"建池失败 → 整进程永久失遥测"这条路彻底拆掉,并让任何降级都可恢复、可见、可编程。
## 方案概述
`min_size=0` 让建池变成零成本动作(实测不触库),连接失败自动落到 `acquire` 那条本来就正确的"丢一行、池自恢复"路径;判死判据从"哪一步失败"改为"失败是什么性质",永久档窄到只剩"DSN 不可解析",其余一律 60s 冷却重试;降级状态升格为共用的一等对象(节流日志 + 只读快照);顺带把"谁建的谁关"统一为全库纪律,让 ARCH §7.7 R5 的显式共享真正可用。
## 保真校验适用性
**不适用**。遥测后端无参考实现蓝本(ARCHITECTURE.md §7.8 明记"参考仓无先例: 三项目遥测全 SQLite"),本计划不涉及 `reference/` 迁移。但有两条**同等强度的既有承诺**不得被本次改动破坏,各任务已挂检查点:
1. issue #13 的"manual 档缺列时裁剪 INSERT 继续写、逐行 warning 暴露"(T5 的 `42703` 例外);
2. issue #9 的"表存在就绝不发 DDL"(`to_regclass` 先探测,T4/T5 不得碰这段控制流)。
## 起点状态(执行前必读)
- **工作区有未提交改动且在 `main` 上**: Python 3.12 迁移已执行完毕(`pyproject.toml` `requires-python`/`target-version``README.md` 两处、`CLAUDE.md` 技术栈、`client.py``streaming.py` 的 UP047 三处改 PEP 695),conda 环境已重建为 3.12.13 并补装 `build`/`twine`。**T0 的第一件事就是把它们落到分支上**。
- **建池路径今天零测试覆盖**: 全 `tests/` 目录对 `create_pool``_open_pool` 的引用数为 **0**(执行前可自行复核)。现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入,走的是 `_external_pool=True` 分支,**从不经过建池**。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要新建这一路的第一个用例。
## 提交门(每个提交点都受此约束)
`.claude/scripts/hooks/pre-commit-guard.sh` 在检测到 `git commit` 时**阻塞式**执行: `ruff check src/`(任何问题即阻塞)、`radon cc src -n C`(圈复杂度 ≥ C 即阻塞)、`pytest tests/ --tb=line -q`(任一红即阻塞)。文件 > 200 行只是 warning,不阻塞。
两条由此而来的硬约束:
- **不得留红态跨提交**——任务边界必须切在"全绿"处,不能把一个行为拆成"改实现"和"改测试"两次提交。
- **圈复杂度是真实风险**: `record_llm_call` 本次要同时接入硬预算、失败分类与 tracker。一旦逼近 C 就必须抽私有方法,**这不算计划外重构**,是提交门的硬要求。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 改 | 新增 `TelemetryStatus` frozen dataclass(与 `SourceStats` 同一先例) |
| `src/polygateway/ports.py` | 改 | 新增**独立** `TelemetryStatusProvider` Protocol;`TelemetryRecorder` **一字不动** |
| `src/polygateway/telemetry/status.py` | **新建** | `TelemetryStatusTracker`: 降级状态机 + 节流日志 + 快照。两个 recorder 共用,不含任何后端知识 |
| `src/polygateway/telemetry/postgres.py` | 改 | 池语义、硬预算、失败三分、冷却降级、有界 `aclose`、接入 tracker |
| `src/polygateway/telemetry/sqlite.py` | 改 | **仅**接入 tracker(补上今天缺失的降级 warning);不做 lazy 化与冷却 |
| `src/polygateway/config.py` | 改 | 两个新键的加载与校验 |
| `src/polygateway/client.py` | 改 | 所有权纪律 + `aclose` helper + `telemetry_status` 出口 |
| `src/polygateway/embedding.py``ocr.py` | 改 | 同款所有权与出口(三处必须一致) |
| `src/polygateway/backends/redis_cache.py` | 改 | 补 `_owns_client` 纪律 |
| `tests/unit/test_telemetry.py` | 改 | `_FakePgPool` 改造 + 池语义/预算/分类/冷却/tracker 用例 |
| `tests/unit/test_client.py` | 改 | 所有权层用例(三个 client 各钉一次) |
| `tests/unit/test_config.py` | 改 | 两个新键的三条装配路 |
| `tests/integration/test_postgres_telemetry.py` | 改 | 真实 PG: 连接数计数、降级恢复 |
| `.env.example``README.md``CHANGELOG.md``research-wiki/ARCHITECTURE.md` | 改 | 配置面、能力表、发布说明、架构决策成文 |
## 关键接口(跨任务消费,此处定死)
`types.py` 新增(T2 建立,T4/T5/T6 消费):
```python
@dataclass(frozen=True)
class TelemetryStatus:
"""遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。"""
degraded: bool
fatal: bool # True = 本进程内不可恢复(仅 DSN 不可解析一类)
reason: str | None # 降级原因;未降级为 None
degraded_for_s: float | None # 已降级时长;未降级为 None
dropped_rows: int # 累计丢弃行数(进程生命周期内单调不减)
retry_after_s: float | None # 距下次重新准备;fatal 或未降级为 None
```
`ports.py` 新增(T2 建立)——**独立于 `TelemetryRecorder`**,理由见设计 §3.3:
```python
@runtime_checkable
class TelemetryStatusProvider(Protocol):
"""可自述可写状态的遥测后端;与 TelemetryRecorder 分开是为了不破坏后者的
runtime_checkable 语义(加成员会让只实现 record_llm_call 的对象当场不满足协议)。"""
@property
def telemetry_status(self) -> TelemetryStatus: ...
```
`telemetry/status.py` 新增(T2 建立,T4/T5 消费)。`now` 注入以便测试推进假时钟:
```python
class TelemetryStatusTracker:
def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None: ...
def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None: ...
def recover(self) -> None: ...
def record_drop(self, reason: str) -> None: ...
def should_retry(self) -> bool: ... # fatal→False;冷却未到→False;到期→True
def snapshot(self) -> TelemetryStatus: ...
```
`PostgresRecorder.__init__` 新签名(T3 落地;`pool_max`/`write_timeout_s` keyword-only **必填**,与 `auto_migrate` 同一纪律——缺省只写在 config 一处):
```python
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool,
pool_max: int, write_timeout_s: float,
now: Callable[[], float] = time.monotonic) -> None: ...
```
`GatewaySettings` 新字段与 env 键(T3 落地):
| 字段 | env 键 | 缺省 | 校验(落 `_validate_telemetry`) |
|---|---|---|---|
| `telemetry_pg_pool_max: int` | `PGW_TELEMETRY_PG_POOL_MAX` | 4 | `>= 1`,否则 ValueError 点出字段名与键名 |
| `telemetry_pg_write_timeout_s: float` | `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 5.0 | `> 0`,同上 |
失败三分(T5 落地,`postgres.py` 模块级私有函数,全库唯一一处 PG 失败分类):
```python
_FATAL = "fatal" # 配置级致命 → 永久 no-op + 一条 error
_UNAVAILABLE = "unavailable" # 环境级 → 60s 冷却降级
_ROW = "row" # 行级 → 逐条 warning 丢弃
def _classify_failure(exc: BaseException) -> str: ...
```
判据(设计 §3.2,两句): ①致命 = 原因完全在进程内部且不可变;②行级 vs 环境级看失败与**这一行的数据**有没有关系。落到具体码:
| 归档 | 覆盖 |
|---|---|
| `_FATAL` | `asyncpg.ClientConfigurationError`;`create_pool` 抛的 `ValueError`/`TypeError` |
| `_UNAVAILABLE` | SQLSTATE 前两位 ∈ {`08`,`53`,`57`,`28`,`3D`} + 具体码 `42501``42P01`;`OSError`/`ConnectionError`/`TimeoutError`/其余 `InterfaceError` |
| `_ROW` | 其余 `PostgresError`(`22`/`23` 等)+ **具名例外 `42703`**(缺列,由 issue #13 承诺定死) |
冷却期为模块级常量 `_DEGRADE_COOLDOWN_S = 60.0`(不暴露配置,设计 §3.5)。
## 任务清单
### T0 — 分支与基线(把已完成的 3.12 迁移落盘)
- [x] 从 `main` 建分支 `feat/issue-15-telemetry-pool-lifecycle`
- [x] 把工作区现有改动分两次提交: ① `chore: 最低 Python 提到 3.12 并改用 PEP 695 泛型语法`(`pyproject.toml`/`README.md`/`CLAUDE.md`/`client.py`/`streaming.py`);② `docs: issue #15 设计文档与 wiki 登记`(`research-wiki/`)
- [x] 记录基线用例计数(执行时实测;2026-08-24 本机为 **973 passed / 23 skipped / 45 deselected**,覆盖率 94%)。该数只作**同环境**参照,不作硬验收——`addopts = "-m 'not slow'"` 与 Redis/PG 可达性都会改变它
**验证**: `make check` 全绿;`/home/iomgaa/miniconda3/envs/PolyGateway/bin/python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确。
> **不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check`
> **不要用 `conda run ... pytest` 取统计数字**——实测其输出缓冲会把结尾的 `N passed` 与覆盖率整段吞掉,只剩 exit code(2026-08-24 踩过)。用环境解释器绝对路径直跑。
---
### T1 — D 组: 资源所有权纪律统一(独立回滚点)
**动**: `src/polygateway/client.py``embedding.py``ocr.py``backends/redis_cache.py`;测试 `tests/unit/test_client.py`
**要实现的行为**: 全库唯一纪律 —— **谁建的谁关,注入的一律不碰**。分两层落:
1. **组件内部自建的连接**归组件自己: `RedisCache``_owns_client`(构造注入 → False;`from_url` → True),`aclose` 自查后再关。这是照抄 `backends/redis/limiter.py:185-191, 318-322` 的既有正确先例,`backends/redis/breaker.py:437-441` 同款。
2. **client 自建的整个组件**归 client: 三个 client 各持 `_owns_transport/_owns_telemetry/_owns_cache/_owns_limiter/_owns_breaker`,**默认全 False**(`__init__` 是全量注入路径,经它传入的一切都是外部的),只有三个工厂在真正自建时置 True。工厂里 `transport` 恒自建(三处工厂都没有 transport 注入参数),`limiter`/`breaker`/`cache`/`telemetry``xxx is None` 判定。
**执行留痕(T1)**: 工厂里既有的 `limiter or _build_limiter(...)` 一律改成了 `is not None` 判定。理由是注入一个 **falsy** 后端时 `or` 会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这不是风格偏好,是所有权判定能成立的**必要条件**,已回写设计 §3.4。
**同时修掉的现存泄漏**: `GatewayClient.__init__` 今天把 limiter/breaker 交给 `RetryMW` 构造(`client.py:156-176`)后自己不留引用(`self._transport`/`_telemetry`/`_cache` 都存了,唯独这两个没存,见 `client.py:203-206`),`aclose` 因此**触达不到**自建的 redis 客户端。三个 client 都要新持 `self._limiter`/`self._breaker` 引用(仅为关闭)。embedding/ocr 的自建点在 `embedding.py:497-498``ocr.py:510-511`
**收敛**: 三处复制的 `getattr(..., "aclose")` 探测(`client.py:268-280``embedding.py:452-461``ocr.py:462-467`)收敛为**一个**内部 helper。SQLite recorder 只有同步 `close()`,helper 须同时探测 `aclose`/`close`(今天 `client.py:274-277` 已有这个分支,embedding/ocr 也有,收敛后行为不变)。内存后端无 `aclose`,探测后跳过。
**测试要求**(先失败后通过): 假 recorder/transport/limiter/breaker/cache 各记 close 次数。
- 注入的组件 `aclose` 后 close 次数 **0**;自建的为 **1**(工厂路径);
- 自建 redis limiter/breaker 被关(**泄漏钉子**,今天必红);
- 注入给 `RedisCache` 的客户端不被关;
- **三个 client 逐一覆盖**——收敛成 helper 之后仍须三处各钉一次,否则下次有人把逻辑复制回去无人发现;
- `aclose` 幂等(连调两次不重复关)。
**验证**: `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py -q` → PASS;`make check` 绿;全套件绿。
- [x] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏`
---
### T2 — C 组基础设施: 状态快照 + tracker + 出口
**动**: `src/polygateway/types.py``ports.py`、**新建** `telemetry/status.py``telemetry/postgres.py``telemetry/sqlite.py``client.py``embedding.py``ocr.py`;测试 `tests/unit/test_telemetry.py``test_ports.py``test_client.py`
**为什么排在 A/B 组之前**: T3/T5 的所有降级点都要向 tracker 报告。先建 tracker 则那两步直接写成最终形态,反之要返工一遍日志代码。
**要实现的行为**:
1. `TelemetryStatus``TelemetryStatusProvider` 按上文"关键接口"定死。**`TelemetryRecorder` 一字不动**。
2. `TelemetryStatusTracker` 状态机: `enter_degraded` 打一条 warning(含原因与恢复条件: 冷却剩余秒数,或 fatal 时写明"需改配置并重启");降级期间 `record_drop` **节流复述**(按丢弃行数与时间双阈值,阈值为模块常量);`recover` 打一条 info 并报告"期间丢弃 N 行";`should_retry` 是纯查询(fatal → False,冷却未到 → False)。
3. 两个 recorder 各持一个 tracker,把**今天已有的**降级点接上去: PG 的建池失败与判死、SQLite 的初始化失败。**SQLite 侧同时补上今天缺失的那条 warning**——`sqlite.py:138-139` 初始化失败后写入直接 `return`,连一条日志都没有。
4. 出口 `telemetry_status` 属性加到三个 client,取值经**一处** `isinstance(self._telemetry, TelemetryStatusProvider)` 判定,不满足或无遥测则返回 `None`
**本任务不改任何失败判据**: PG 侧仍是"建池失败即永久判死",只是这次判死会经 tracker 变得可见。判据在 T5 改。这样本任务的行为变更面收敛为"日志更可见 + 多一个只读出口"。
**过渡期状态并存(有意,且必须在 T5 收掉)**: 本任务结束时 PG 侧的 `_failed` 布尔与 tracker 的 fatal 状态**并存**——判死点两边都写。这是为了让 T2 能独立全绿提交,不是最终形态;T5 删除 `_failed`,状态收归 tracker 一处。两份状态只允许存活这一个任务的跨度,拖久了必然漂移。
**契约检查点**: `tests/unit/test_ports.py:137,141``isinstance(_DummyRecorder(), TelemetryRecorder)` 断言必须**保持绿**——它是"没把状态并进主 Protocol"这条决策的机械化执法点,新增用例不得替代它。
**测试要求**(先失败后通过):
- tracker 状态机六字段逐个钉: 未降级 → `degraded=False` 且三个可空字段为 None;进入降级 → `reason`/`retry_after_s` 正确;假时钟推进 → `degraded_for_s` 增长、`retry_after_s` 递减到 0;`recover` → 回到未降级且 `dropped_rows` **不清零**(进程生命周期内单调不减);
- 节流复述: 连续 N 次 `record_drop` 只产生 M 条 warning(loguru sink 捕获断言),且 N 与 M 的关系由常量决定而非硬编码数字;
- fatal 档: `should_retry()` 恒 False,`retry_after_s` 为 None;
- SQLite 初始化失败(指向不可写目录)→ 有 warning **且** `telemetry_status.degraded is True`(今天必红,连 warning 都没有);
- 三个 client 的 `telemetry_status`: 无遥测 → None;注入不实现该 Protocol 的假 recorder → None(不得抛 AttributeError);内置 recorder → 返回快照。
**验证**: `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_client.py -q` → PASS;`lint-imports` 绿(新文件 `telemetry/status.py` 在实现层,只许依赖 `types`/`ports`/标准库,**不得**被 `transports`/`backends` import);全套件绿。
- [x] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)`
---
### T3 — A 组: 池语义与两个新配置键
**动**: `src/polygateway/config.py``client.py`(`_build_telemetry`)、`telemetry/postgres.py`;测试 `tests/unit/test_config.py``test_telemetry.py`、**`tests/integration/test_postgres_telemetry.py`**。
> **本任务必须一次改完全部 20 处 `PostgresRecorder(` 构造点**(Codex 审查,已实测复核): `src/polygateway/client.py` 1 处 + `tests/unit/test_telemetry.py` 3 处 + **`tests/integration/test_postgres_telemetry.py` 16 处**。新签名的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**,漏一处就 `TypeError`,而提交门跑的是**全套件**——集成测试那 16 处不能拖到 T6,否则 T3 根本提交不了。这是 `auto_migrate` 当初(issue #13)踩过的同一形态: 必填 keyword-only 的代价就是所有构造点同批改。
**要实现的行为**:
1. 两个新配置键按"关键接口"那张表落地: `_load_pgw` 里读取(模板照 `config.py:524-543``_load_text_cap`),值域校验落 `_validate_telemetry`(与 `telemetry_text_cap` 同一先例,**一次覆盖直接构造 / `dataclasses.replace` / env 三条路**),报错文本同时点字段名与 env 键名。`_build_telemetry`(`client.py:405-420`)把两个值透传给 recorder。
2. 建池改为 `create_pool(dsn, min_size=0, max_size=pool_max, timeout=write_timeout_s, command_timeout=write_timeout_s)`
3. **两处** `acquire` 都改为**显式** acquire/release,**不得**用 `async with pool.acquire(...)`——`_prepare_schema`(`postgres.py:114`)与 `record_llm_call`(`postgres.py:238`)。准备期同样在预算内、同样吃 shielded release 那一刀,只改一处等于留了半个坑:
- `con = await pool.acquire(timeout=write_timeout_s)`(传**完整**预算: 真正的上界是外层 `asyncio.timeout`,内层再算一次剩余量等于把同一个上界写两遍);
- `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`;
- 整次写入(准备 + acquire + execute)由 `asyncio.timeout(write_timeout_s)` 包一层。
**理由(设计 §3.1,已核实)**: `Pool.release()``await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `__aexit__`,此时没有新的 cancel 投递,那个 shielded release 会**正常等到完成**——用 `async with` 的真实上界是 ≈ 2 × 预算。
**必须同步改造 `_FakePgPool`**(`tests/unit/test_telemetry.py:751`): 它今天的 `acquire()` **无参**且只返回一个 `_Ctx` 异步上下文管理器,没有 `release`。改造为接受 `timeout=` 并提供 `release(con, timeout=)`,同时记录 acquire/release 的配对次数(T3 与 T5 的用例都要用)。不改造则全部 PG 用例当场红。
**取消穿透的实现纪律**(铁律): 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`。既有 `postgres.py:101-102``except asyncio.CancelledError: raise` 写法是对的,延续它。
**测试要求**(先失败后通过。注意: 建池路径**今天零覆盖**,这里要建立第一个用例):
- **主回归钉子**: monkeypatch `asyncpg.create_pool`,断言实参 `min_size == 0``max_size == 配置值`。这一条防的是回归到继承第三方默认值,是本 issue 的核心;
- 配置键三条装配路: env 路读取正确、缺省为 4 / 5.0、直接构造与 `replace` 同样被校验拦住(`pool_max=0``write_timeout_s=0` 各一条,断言报错文本含字段名与键名);
- 硬预算: 假 pool 的 acquire 挂住 → 丢一行且耗时 ≤ 预算(用假时钟或极小预算,**不要**在用例里真睡 5 秒);
- **release 不泄漏**(Codex 审查钉子): `execute` 被预算取消后,断言 `_FakePgPool` 记录的 acquire/release 次数**配对**;
- 外部 `CancelledError` 在预算内**不**被吞成 `TimeoutError`(直接钉铁律)。
**验证**: `pytest tests/unit/test_config.py tests/unit/test_telemetry.py -q` → PASS;`make check` 绿;全套件绿。
- [x] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算`
---
### T4 — B 组之一: 有界关闭
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`
**为什么单列一个任务**: 它与 T5 的失败判据无关,但同属"收尾路径的隐性无界等待",且能独立验证。合进 T5 会让那次提交同时动判据与关闭两件事,回滚粒度变粗。
**要实现的行为**: `aclose()` 语义钉死为"关了就是关了"——置 `_closed`,此后写入短路且**不复活**(取消今天"关完还能自己重建池"的灰色状态);关闭动作本身走 `asyncio.wait_for(pool.close(), timeout=...)`,超时后 `pool.terminate()`,外部取消照常穿透。
**理由(已核实)**: `Pool.close()``await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(asyncpg `pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着 "advisable to use `asyncio.wait_for` to set a timeout"。
**测试要求**(先失败后通过):
- 假 holder 永不 release → `aclose()` 在超时后走 `terminate()` 返回,**不无限挂**(今天必红/挂死,用例须自带超时保护);
- `aclose` 后再 `record_llm_call` → 直接短路,**不重建池**(断言 `create_pool` 未被再次调用);
- `aclose` 幂等;注入的外部池仍**不**被关(`_external_pool` 既有纪律不得破)。
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;全套件绿。
- [x] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活`
---
### T5 — B 组之二: 失败三分与冷却降级(本 issue 的核心)
**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`
**要实现的行为**:
1. 新增模块级 `_classify_failure`(按"关键接口"的三档表),全库唯一一处 PG 失败分类。
2. 三个降级点改为按分类处置: `_open_pool``_prepare_schema`/`_prepare_table``record_llm_call`
- `_FATAL` → 永久 no-op + 一条 **error**(不是 warning: 这是人配错了),经 tracker 置 `fatal=True`;
- `_UNAVAILABLE``tracker.enter_degraded(cooldown_s=_DEGRADE_COOLDOWN_S)`,此后 `_ensure_ready` 开头零成本短路(只比较时间戳,不触库),到期 `should_retry()` 放行**一次**重新准备,成功即 `tracker.recover()`;
- `_ROW` → 逐条 warning 丢弃 + `tracker.record_drop()`,不降级。
3. **删除 `_failed` 这个布尔**,状态收归 tracker 一处(否则两份状态必然漂移)。实测引用分布(执行时可自行复核): `src/polygateway/telemetry/postgres.py` **7 处**(74/79/84/106/124 是代码,209/211 在 `_backfill_columns` 的 docstring 里——**文档也要改**,否则留下指向已删字段的说明)、`tests/unit/test_telemetry.py` **6 处**`tests/integration/test_postgres_telemetry.py` **6 处**,测试侧一并改为读 `telemetry_status` 快照。
4. 判据的两条既有承诺不得破:
- **`42703` 仍走 `_ROW`**(issue #13: manual 档缺列时裁剪 INSERT 继续写、逐行暴露)。这是判据的**唯一具名例外**,代码里必须有注释写明它是例外及理由;
- **`_prepare_table``to_regclass` 先探测、表在就不发 DDL** 这段控制流(`postgres.py:147-157`)一行不动(issue #9)。
**圈复杂度检查点**: 本任务是三个降级点同时改,`record_llm_call``_ensure_ready` 最容易触到 radon 的 C 档而被提交门阻塞。逼近就抽私有方法(如 `_handle_failure(exc, *, stage)` 收敛三处处置)——这是提交门的硬要求,不算计划外重构。
**测试要求**(先失败后通过,分档逐个钉):
- **issue 场景直接回归**: 建池阶段抛 `TooManyConnectionsError`(53300)→ **不** fatal、进冷却降级 → 假时钟推进 60s → 下次调用自动恢复并成功写入。今天这一条必红(现状是永久判死);
- `ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);
- **分档边界两侧各钉一次**: `42501`/`42P01` → 进冷却降级;`42703` → 行级丢弃且**不**进降级;
- `_prepare_table` 建表失败(表确定不存在)→ 冷却降级(不再是永久判死),DBA 建表后自动恢复;
- 探测失败(既有 `probe_errors` 路径)仍只跳过本次、下次重试,**不**降级(issue #9 既有行为不得回归);
- 全部现有 PG 用例保持绿(它们钉的是 issue #3/#9/#13 的承诺)。
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;`radon cc src/polygateway/telemetry/postgres.py -n C -s` → 无输出;全套件绿。
- [x] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈`
---
### T6 — 真实 PG 集成验证
**动**: `tests/integration/test_postgres_telemetry.py`
**纪律(该文件既有,不得破)**: `llm_calls` 是与真实批跑共享的表,**严禁 DROP/TRUNCATE**;以 run 级 `call_id` 前缀隔离,teardown 只删自己的行;DSN 缺失则 skip;不标 `slow`(与该文件既有用例一致)。
**要实现的行为(用例)**:
1. **issue 的直接回归钉子**: 建 recorder 后本池连接数为 **0**,一次写入后 **≤1**,稳态 ≤ `pool_max`
2. 降级与恢复走**不可达 DSN** 的 recorder 验证(连接被拒 → 降级 → 假时钟/短冷却后重试),**不去动共享实例的 `max_connections`**。
**计数必须按唯一 `application_name` 过滤**,该实例被多项目共用,按库名或用户名计数会被别人的连接污染——那样的用例是**设计上就会间歇红**的信号污染源(CLAUDE.md §4.6)。
**怎么设这个 tag(Codex 指出原稿这里无法执行,已实测给出解法)**: recorder 的构造签名**没有** `server_settings`/`connect_kwargs` 入口,原稿那句"经 `server_settings=` 建池"落不了地。解法是走 **DSN 查询参数**——给 recorder 一个 `f"{dsn}?application_name={run级唯一值}"`,其余一切不变。
- 已实测(2026-08-24,真实实验室 PG): `create_pool(dsn + "?application_name=pgwtest-abc123", min_size=0, ...)``SHOW application_name` 返回该值,`pg_stat_activity` 按它过滤得连接数 1,`pool.close()` 后归零。
- **不要**改用"测试自建池后以 `pool=` 注入": 那会走 `_external_pool=True` 分支、**完全绕过被测的建池路径**,而本任务要验的恰恰是自建池不预连接。
- **不要**为此给 recorder 加 `server_settings` 入口: 纯测试便利不值得扩公共 API(P1)。
- 注意 `config.py``_strip_dsn_driver` 只动 scheme 的 `+driver` 后缀,不碰查询参数;且集成测试直接构造 recorder、不经 config,两条路都不受影响。
**验证**: `pytest tests/integration/test_postgres_telemetry.py -q` → PASS(或无 DSN 时全 skip);全套件绿。
- [x] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈`
---
### T7 — 文档、配置面与发布说明
**动**: `.env.example``README.md``CHANGELOG.md``research-wiki/ARCHITECTURE.md`
**要实现的行为**:
1. `.env.example`: 两个新键写在 `PGW_TELEMETRY_PG_DSN` 之后,沿用该文件既有的"键 + 缩进注释块讲清为什么"风格。`pool_max` 必须给**调参口径**: 写**实测值**而非 `pool_max / RTT`(T3 实测该公式乐观一倍,见设计 §10 修订 #1)——跨内网 RTT ≈ 123ms 上 `pool_max=4`**15.6 行/秒**(50 行并发批 3.2s),并写明"共享一个 recorder 给多 client 时并发汇聚,应相应放大"。
2. `README.md`: 配置表加两键;能力表反映"遥测降级可恢复 + 可查询状态";**核对安装命令里的版本约束**(发布清单第 1 步的老账: `==1.2.*` 这类极易漏改)。
3. `ARCHITECTURE.md` §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分的**两句判据**、**资源所有权纪律**(后者应作为跨子系统的通用纪律成文,而非遥测局部约定);§9 登记两个新键。
4. `CHANGELOG.md`: 记在"未发布"下,三处"请先读这一条": ①最低 Python 提到 3.12(**唯一会让下游装不上**的变更);②遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);③`aclose` 不再关闭注入的组件。
**验证**: `make check` 绿;人工通读 `.env.example` 两键注释,确认调参口径可执行。
- [x] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文`
---
## 完成判据(合并前)
- [x] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件)
- [ ] `pytest -m slow` 单独跑过一次(发布清单第 4 步;本次改动触及遥测写入路径,e2e 与 Redis 时间语义变体必须实测)
- [ ] 派**全新上下文**的 verifier subagent 独立验证(`verification-before-completion`,里程碑级/合并前 MANDATORY)
- [ ] 整分支审查(`requesting-code-review`,合并前 MANDATORY)
- [ ] 设计文档 §5 的每一条测试要求都能指到一个具体用例(逐条对照,不是"大致覆盖")
## 审查留痕(Codex,2026-08-24)
**Status: Issues Found → 2 条阻断级均已修订,2 条 Recommendation 采纳 1 条。**
| # | 结论 | 落点 |
|---|---|---|
| 1 | **采纳(阻断)**。新签名的 `pool_max`/`write_timeout_s` 是必填 keyword-only,而 `PostgresRecorder(`**20 处**构造点,其中 **16 处在集成测试**。原稿 T3 只列了两个单元测试文件,漏掉的那 16 处会让 T3 的提交门(跑全套件)当场红 | T3 "动"一节 |
| 2 | **采纳(阻断),并给出比建议更好的解法**。原稿 T6 写"经 `server_settings=` 建池"设唯一 `application_name`,但 recorder 签名根本没有这个入口,零上下文执行者会卡死。Codex 给的两条出路(注入外部池 / 加 recorder 入口)都有代价——前者绕过被测的建池路径,后者为测试便利扩公共 API。**实测发现第三条**: `?application_name=<tag>` 走 DSN 查询参数,asyncpg 认、PG 侧生效、关池后计数归零,**零 API 改动且真实覆盖建池路径** | T6 计数一节 |
| 3 | **采纳(建议)**`_failed` 计数原稿写"测试 13 处"不准。实测: 源码 7 处(**含 2 处在 docstring 里**,文档也要改)、unit 6 处、integration 6 处 | T5 第 3 点 |
| 4 | 无需动作。Codex 复核确认了计划的两条硬断言: 建池路径零覆盖(`rg create_pool\|_open_pool tests` 无匹配)、`_FakePgPool` 定义于 `:751-765` 且只经三个 helper 注入(故改造类本身即可覆盖既有假池用例) | — |
Codex 给的 `_failed` 分布数字(源码 5 处 / 测试断言 8 处)与本地实测(源码 7 / unit 6 / integration 6)不一致,以实测为准——它漏了 docstring 里那两处,而那两处恰恰是**必须改**的(留着就是指向已删字段的说明)。
## 实际提交(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)
| 任务 | hash | message 首行 |
|---|---|---|
| T0 ① | `157a27f` | `chore: require python 3.12 and adopt PEP 695 type parameters` |
| T0 ② | `e7caa50` | `docs: plan the telemetry pool lifecycle rework for issue 15` |
| T1 | `e69ca4c` | `fix: make every client close what it built and nothing else` |
| T2 | `f958138` | `feat: make telemetry degradation a first-class state` |
| T3 | `84c2cc1` | `feat: make the telemetry pool declare what it costs` |
| T4 | `bc071c6` | `fix: make closing the telemetry pool bounded and final` |
| T5 | `eef2fdc` | `fix: judge telemetry failures by nature, not by step` |
| T6 | `bfeda5b` | `test: prove on real PG that the pool never preconnects` |
| T7 ⓪ | `69a5b5f` | `test: pin the cooldown assertion to a fake clock`(T5 留下的一处间歇红: 快照里的 `retry_after_s` 是时间差,却用真实时钟断言 60.0) |
| T7 ① | `7834d75` | `feat: export TelemetryStatus from the package root` |
| T7 ② | `4e1f09d` | `docs: record the telemetry pool semantics and ownership rule`(本表的 hash 由紧随其后的一次 bookkeeping 提交补齐) |
| T8 ① | `f90f7b0` | `test: give the log level and ownership rules real enforcement` |
| T8 ② | `6d6b3cf` | `docs: correct the stale throughput numbers and wiki state`(本行 hash 由紧随其后的 bookkeeping 提交补齐) |
**T8 不在原计划内**: 它是合并前独立验证(全新上下文 verifier)报出的 5 个问题的处置——2 条"确证的假绿"(日志级别与所有权判定各自没有执法点)+ 2 处过时数字/措辞 + 1 处 wiki 状态漂移。详见设计 §10 修订 #4/#5
T7 分两次提交是因为它含一处**公共 API 面**改动(`TelemetryStatus` 进顶层 `__all__`,决策见下),与纯文档的回滚粒度不同。
**T7 执行期追加的决策与发现**(计划原稿只列了四项文档任务):
| # | 内容 | 落点 |
|---|---|---|
| 1 | `TelemetryStatus``polygateway.__all__`。issue #15 的核心诉求之一是下游能**编程对账**,而 `client.telemetry_status` 的返回类型若不能从顶层 import,下游做类型标注就得深入 `polygateway.types`——与"顶层导出即公共 API 面"的约定冲突。T2 参照的 `SourceStats` 先例**不适用**: 那是端口内部快照、下游不消费。端口 `TelemetryStatusProvider` 仍不导出 | `__init__.py``tests/unit/test_package.py`、ARCH §7.8 |
| 2 | 吞吐算术更正为实测值(15.6 行/秒),`.env.example` / README 的调参口径按实测写 | 设计 §3.5/§6/§10 |
| 3 | "重试建池已零成本"这条红利与"关闭后偷偷复活"这个 bug 分别补进设计 §3.2 / §1.5 | 设计 §10 |
@@ -0,0 +1,529 @@
---
type: plan
node_id: plan:2026-08-25-thinking-observability-plan
title: "推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)"
date: 2026-08-25
---
# 推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1
> 类型:plan|日期:2026-08-25|实现设计:`designs/2026-08-25-thinking-observability-design.md`(已经人类批准)
> 事实基础:`findings/2026-08-25-thinking-observability-regression.md`
> **保真校验不适用**:本计划不涉及 `reference/` 三项目的迁移,推理开关是库自有子系统,不在 ARCHITECTURE.md §1.4 关键资产索引的移植蓝本内。
## 目标
让"这次推理到底发生没发生"成为库的一等返回值,由多信号裁定,判不出来时如实说 UNKNOWN,并与能力表持续对账。
## 方案概述
新增 `ThinkingObservation` 三态枚举(定义在最内层 `types.py`)与裁定纯函数 `observe_thinking`(决策层 `thinking.py`),由 transport 在组装结果时裁定并与请求方向对账,结果随 `LLMResponse` 返回、随遥测落库。同时把推理决策从 `providers.py` 拆进新模块 `thinking.py`,并把公共符号提升到包根导出。
涉及技术:Python 3.12 `StrEnum`、frozen dataclass、`inspect.signature` 冻结测试、import-linter 分层契约、SQLite/PG schema backfill。
## 文件结构
**新建**
| 文件 | 职责 |
|---|---|
| `src/polygateway/thinking.py` | 推理这件事的全部**决策**:能力表、`resolve_thinking`(请求侧注入)、`observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义** |
| `tests/unit/test_thinking.py` | 裁定与对账的单元测试 |
**修改**
| 文件 | 变更 |
|---|---|
| `src/polygateway/types.py` | 新增 `ThinkingObservation``LLMResponse` / `TransportResult` 各增一字段 |
| `src/polygateway/providers.py` | 收缩为纯注册表 |
| `src/polygateway/ports.py` | `record_llm_call` 24 参 → 25 参 |
| `src/polygateway/transports/openai_compat.py` | 裁定 + 对账 |
| `src/polygateway/middleware/retry.py` | 透传 |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage` + 三个 `emit_*` + `_record` |
| `src/polygateway/middleware/cache.py` | `_rehydrate` 枚举复活 |
| `src/polygateway/telemetry/schema.py` | 新列 + 两端 DDL + 两份 backfill |
| `src/polygateway/telemetry/sqlite.py``postgres.py` | 实现新参 |
| `src/polygateway/client.py` | import 路径 |
| `src/polygateway/__init__.py` | 包根导出 + 版本号 |
| `pyproject.toml` | import-linter 契约加层 + 版本号 |
| 测试 9 个、文档 5 个 | 见各任务 |
---
## Task 1`ThinkingObservation` 与裁定纯函数
**文件**:创建 `src/polygateway/thinking.py``tests/unit/test_thinking.py`;修改 `src/polygateway/types.py``pyproject.toml`
### 行为
`types.py` 新增(放在 `LLMResponse` 定义**之前**,因为它是其字段类型):
```python
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态不可折叠为布尔: `UNKNOWN` 是"本次无任何信号,判不出来",与
`ABSENT`("上游明确上报未推理")语义不同。把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义——库据此静默宣称"没推理",
而实际可能推理了且已计费(MiniMax-M3 非流式实测)。
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
```
在新建的 `thinking.py` 实现(本任务只放这一个函数,搬迁留给 Task 2):
```python
def observe_thinking(
*, thinking: str, reasoning_tokens: int | None
) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按证据硬度排序。
推理正文是事实本身,token 计数是对事实的转述——转述缺失时事实仍然作数。
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
if reasoning_tokens is None:
return ThinkingObservation.UNKNOWN
return (
ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
)
```
`pyproject.toml` 的 import-linter 契约 `layers` 插入一层,位置在实现层与 `providers` 之间:
```toml
layers = [
"polygateway.client",
"polygateway.config",
"polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
]
```
层序理由:`thinking.py` 要 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下)。**枚举放 `types.py` 而非 `thinking.py`,正是为了让最内层不反向依赖决策层**——这是本任务最容易做错的一步,写反了 import-linter 会判红。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py` 覆盖裁定五种输入:正文非空 → OBSERVED;**纯空白正文 + `reasoning_tokens=None` → UNKNOWN**(不得因 truthy 判成 OBSERVED);`reasoning_tokens=5` → OBSERVED`reasoning_tokens=0` → ABSENT`reasoning_tokens=None` 且正文空 → UNKNOWN。再加一条优先级用例:正文非空且 `reasoning_tokens=0` → OBSERVED(正文压倒转述)。
`tests/unit/test_types.py` 加一条:`ThinkingObservation` 定义在 `polygateway.types` 模块内(`ThinkingObservation.__module__ == "polygateway.types"`),防止后续任务把它挪回决策层。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_types.py -v
conda run -n PolyGateway lint-imports
```
预期:新测试全 PASS`lint-imports` 全部契约 KEPT。
- [ ] Task 1 提交:`feat: judge whether reasoning actually happened from multiple signals`
---
## Task 2:把推理决策从 `providers.py` 搬进 `thinking.py`
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/providers.py``src/polygateway/client.py``src/polygateway/transports/openai_compat.py``src/polygateway/__init__.py``tests/unit/test_providers.py``tests/unit/test_package.py`
### 行为
`providers.py` **原样移入** `thinking.py`(纯移动,不改逻辑):`ThinkingUnsupportedError``ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking``_warn_unregistered`
`providers.py` 保留:`ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider`。其模块 docstring 改为只讲注册表职责;`thinking.py` 的模块 docstring 说明它承载推理的全部决策而枚举归 `types.py`
更新 import`client.py``from polygateway.providers import get_capability, get_provider, resolve_thinking` 拆成两行)、`transports/openai_compat.py``client.py``TYPE_CHECKING` 块里 `ThinkingCapability` 的来源。
`__init__.py` 新增包根导出并加进 `__all__`(该列表**不是严格字母序**——`DEFAULT_PROFILES` 现在就排在 `AllSourcesExhausted` 前面;沿用文件既有排列,把新符号插到同类符号附近即可):`ThinkingCapability``ThinkingObservation``ThinkingUnsupportedError``get_capability``register_capability``resolve_thinking`
`tests/unit/test_providers.py` 里针对被搬走符号的测试,整体移入 `tests/unit/test_thinking.py`
### 测试要求(先失败后通过)
`tests/unit/test_package.py` 比照既有 `TelemetryStatus` 用例,加一条断言六个新符号可从包根 import 且在 `__all__` 内——该测试在导出落地前必然红。
搬迁本身的回归证据:搬迁前后 `pytest tests/unit -q` 通过数不减(搬迁是纯移动,任何行为差异都是 bug)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit -q
conda run -n PolyGateway lint-imports
conda run -n PolyGateway python -c "from polygateway import ThinkingObservation, ThinkingCapability, resolve_thinking; print('ok')"
```
预期:全 PASS;契约 KEPTimport 成功。
- [ ] Task 2 提交:`refactor: give reasoning decisions their own module`
---
## Task 3:字段落到响应类型并贯通调用链
**文件**:修改 `src/polygateway/types.py``src/polygateway/transports/openai_compat.py``src/polygateway/middleware/retry.py`;测试 `tests/unit/test_types.py``tests/unit/test_openai_compat.py``tests/unit/test_retry.py`
### 行为
`TransportResult``LLMResponse` 各新增字段,**必须加在各自字段列表末尾且带默认值**(`LLMResponse` 是被三项目消费的公共类型,只增不删且不得改变既有位置参数顺序):
```python
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
```
`LLMResponse` 侧补 docstring`UNKNOWN` = 本次无信号判不出,**不是**"没推理";非流式路径下部分模型推理已计费却不回传正文(M3 实测 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`
`transports/openai_compat.py` 的两条组装路径(流式 `_complete_stream` 的 463-475 行、非流式 `_complete_once` 的 548-560 行)在构造 `TransportResult` 时调 `observe_thinking(thinking=thinking, reasoning_tokens=...)` 填入。两条路径都要填——**只填一条正是 L5 要抓的那类分叉**。
`middleware/retry.py``_build_response`372-393 行)透传 `thinking_observation=result.thinking_observation`
### 测试要求(先失败后通过)
`tests/unit/test_types.py`:两个类型的默认值均为 `ThinkingObservation.UNKNOWN``LLMResponse` 既有位置构造方式不破(沿用文件内既有的构造用例形态)。
`tests/unit/test_openai_compat.py`:用既有的 SSE / JSON 响应装置,构造三种响应各断言一次——含 `reasoning_content` 增量 → `OBSERVED`;无推理信号 → `UNKNOWN``usage.completion_tokens_details.reasoning_tokens=0``ABSENT`。流式与非流式各一组。
`tests/unit/test_retry.py`:比照既有透传测试,断言 transport 返回的 `thinking_observation` 原样出现在 `LLMResponse` 上。
以上在字段落地前全部红(属性不存在)。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v
```
预期:全 PASS。
- [ ] Task 3 提交:`feat: carry the reasoning verdict through to LLMResponse`
---
## Task 4:对账告警(声明 × 观测)
**文件**:修改 `src/polygateway/thinking.py``src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_thinking.py``tests/unit/test_openai_compat.py`
### 行为
`thinking.py` 新增对账纯函数,返回告警文案或 `None`(**判定与日志分离**,这样告警内容可被单测直接断言,不必去解析日志):
```python
def reconcile_thinking(
*,
enable_thinking: bool | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
"""
```
判定矩阵(设计 §5):
| `enable_thinking` | observation | capability | 返回 |
|---|---|---|---|
| `False` | OBSERVED | 已登记 | 能力表漂移:声明可关闭,实测推理了。附 `capability.evidence``register_capability` 指路 |
| `False` | OBSERVED | `None` | 关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `True` | ABSENT | 任意 | 注入了开启参数,上游明确上报未推理 |
| `True` | UNKNOWN | 任意 | 推理参数已注入但本路径观测不到,无法确认是否生效;若为非流式路径,推理内容可能已计费却不回传 |
| 其余组合(含 `False`×UNKNOWN、`None`×任意) | | | `None` |
`False`×UNKNOWN 返回 `None` 是刻意的:`UNKNOWN` 没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。
`transports/openai_compat.py` 在组装完 `TransportResult` 后调用它,非 `None``logger.warning`,并按 `(model, enable_thinking)` 节流——新增实例级 `set`,与既有 `_warned_models` 同款形态,**不可复用同一个 set**(那个 set 语义是"未登记能力已告警过",混用会互相压制)。
### 测试要求(先失败后通过)
`tests/unit/test_thinking.py`:矩阵四行各断言返回非 `None` 且文案含模型名;三种不表态组合(`False`×UNKNOWN、`None`×OBSERVED、`True`×OBSERVED)断言返回 `None`;已登记 vs 未登记两行的文案**必须不同**(不得对未登记模型说"能力表声称可关闭")。
`tests/unit/test_openai_compat.py`:断言同一 `(model, direction)` 连调两次只出现一条 warning;换 direction 后再出一条。**不能用 `caplog`**——本项目日志走 loguru,不经标准 `logging`,`caplog` 抓不到;复用 `tests/unit/test_thinking.py``_warnings()`(`logger.add` 收集)。
> `reconcile_thinking` 必须定义在 `ThinkingCapability` **之后**:本模块没有 `from __future__ import annotations`,注解在 `def` 时求值,放在文件上部会 `NameError`
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_openai_compat.py -v
```
预期:全 PASS。
- [ ] Task 4 提交:`feat: warn when the capability table and reality disagree`
---
## Task 5:缓存回放复活枚举
**文件**:修改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
### 行为
`_rehydrate``LLMResponse(**fields)`JSON 里的 `"observed"` 会复活成**裸 `str`** 而非枚举实例,类型与注解分叉。在 `fields.update(...)` 之前显式转换:
```python
if "thinking_observation" in fields:
fields["thinking_observation"] = ThinkingObservation(
fields["thinking_observation"]
)
```
非法值(旧版本缓存、人为污染)会抛 `ValueError`,由既有的 `except Exception` 吞成"按未命中回源"并 warning——降级方向正确,不需额外处理。
`_serialize` 无需改动:`StrEnum``str` 子类,`dataclasses.asdict` + `json.dumps` 直接可序列化。
### 测试要求(先失败后通过)
`tests/unit/test_cache.py`:写入一条 `thinking_observation=OBSERVED` 的响应后命中回放,断言 `isinstance(resp.thinking_observation, ThinkingObservation)`(改动前必然红——回放出来的是 `str`);再造一条 `thinking_observation``"bogus"` 的缓存值,断言按未命中回源。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_cache.py -v
```
预期:全 PASS。
- [ ] Task 5 提交:`fix: revive the reasoning verdict as an enum, not a bare string`
---
## Task 6:遥测新增一列(端口 → schema → recorder → emitter
**文件**:修改 `src/polygateway/ports.py``src/polygateway/telemetry/schema.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_ports.py``tests/unit/test_telemetry.py``tests/integration/test_postgres_telemetry.py`
### 行为
**端口**`TelemetryRecorder.record_llm_call``thinking_observation: str`**不设默认值**(该 Protocol 的既有纪律,docstring 已写明理由:库外无第三方实现者,带默认值会让 emitter 漏传时静默落默认)。参数加在 `meta` 之后。docstring 的"24 字段冻结"改为 25。
**schema**`SQLITE_DDL` / `PG_DDL` 末尾加 `thinking_observation TEXT``SQLITE_BACKFILL` / `_PG_BACKFILL_DECLS` 各加 `("thinking_observation", "TEXT")``COLUMNS` 末尾加同名项。**新列必须排在最末**——旧表只能 ALTER 追加到末尾,插在中间会让新建库与补列库的物理列序分叉(该纪律的注释就在这两个常量上方)。
**recorder**:两个 recorder 的 `record_llm_call` 都是 `(self, **fields: object)` 形态(**不是**显式参数列表),按 `COLUMNS` / `self._columns``fields` 取值——新列因此**不需要改签名**,只要 `COLUMNS` 里有、emitter 传了,取值就自动到位。要做的是核对两处:取值是否严格按列序、manual 档列裁剪路径是否覆盖新列。`sqlite.py:146` docstring 的"24 字段冻结签名"改 25。
> 端口 `ports.py` 的 Protocol 是**显式 25 参**,而实现是 `**fields`——这不矛盾:Protocol 声明的是调用契约(emitter 必须按名传全),实现选择用 kwargs 收。改端口签名仍然必要,它是 emitter 侧的编译期约束与冻结测试的锚点。
**emitter**`_AttemptUsage``thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN`**内部字段用枚举类型**,裸 `str` 归一化只发生在下沉 recorder 那一步),`of()` 从 response 取;三个 `emit_*` 各传一行(`emit_terminal_failure``ThinkingObservation.UNKNOWN`——无响应可言,默认值本身不撒谎);`_record` 签名增一参并下沉给 recorder。**所有新增字段只经 `_record` 这一个出口抵达 recorder,不新开调用点**(铁律:遥测调用点收敛为单一 helper,该出口已存在)。`middleware/telemetry.py:135` 的"组装 24 字段"改 25。
**recorder 收到的必须是裸 `str`,不是枚举实例**`_AttemptUsage.thinking_observation` 内部用 `ThinkingObservation` 类型,但 `_record` 下沉给 recorder 时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只会被降级成一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一先例(`ports.py` docstring 明载该分工:recorder 只落库,不做语义判断)。
### 数字断言逐处更新(漏一处即红)
| 位置 | 现值 → 新值 |
|---|---|
| `tests/unit/test_telemetry.py:37` `_EXPECTED_COLUMNS` | 末尾加 `thinking_observation` |
| `tests/unit/test_telemetry.py:184` INSERT 占位符串 | 补到 `$25` |
| `tests/unit/test_telemetry.py:210` | `len(COLUMNS) == 24``25` |
| `tests/unit/test_telemetry.py:633` docstring | 物理列 `23 → 25` 改为 `24 → 26` |
| `tests/unit/test_telemetry.py:642` | `== 25``== 26` |
| `tests/unit/test_telemetry.py:645` docstring | `25 个物理列``26 个` |
| `tests/integration/test_postgres_telemetry.py:764` 注释 | `22 → 24 个 recorder 字段(加 created_at 共 25 个物理列)` 改为 `24 → 25 个(共 26 个物理列)` |
> 上表**不完整**——实施时实测另有 6 处漏改会当场把测试跑红:`_FROZEN_SQLITE_INSERT`(计划只点了 PG 那条)、`:586``_EXPECTED_COLUMNS[:-2]``[:-3]``TestBackendColumnParity``COLUMNS[-2:]` 断言、两处 `_CURRENT` 假列表(稳态不发 ALTER 的断言)、`PG_BACKFILL[-1]` 末位断言,以及 integration 侧 `:608``_PRE_TENANT_COLUMNS` 派生式。另有四处注释/docstring 的字段数会过期。**结论: 不要照表逐条打勾就收工,以"全套件绿"为准**。
> **不要改 `tests/unit/test_telemetry.py:1787`**:那里的"共 24 字"是 OCR 占位串 `<ocr:text image_bytes=3>` 的**字符数**,与遥测列数无关。全局替换"24"会误伤它。
### 测试要求(先失败后通过)
`tests/unit/test_ports.py`:现有 `TestTelemetryRecorderSignature` **并不冻结完整参数列表**——它只 parametrize 了 `["tenant_id", "meta"]` 两项,断言其无默认值且为 KEYWORD_ONLY。把 `thinking_observation` 加进该 parametrize 列表,断言同样三条——改端口前必然红。
`tests/unit/test_telemetry.py`:列数与列序断言(上表);新增一条 round-trip——记录一条 `thinking_observation=OBSERVED` 的调用后从 SQLite 读回该列等于 `"observed"`
`tests/integration/test_postgres_telemetry.py`:既有 backfill 用例覆盖旧表补列后新列存在且可写读。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
conda run -n PolyGateway python -c "
import inspect
from polygateway.ports import TelemetryRecorder
p = inspect.signature(TelemetryRecorder.record_llm_call).parameters
print('recorder 参数数(不含 self):', len(p) - 1)"
```
预期:全 PASS;最后一条打印 `25`(README 的字段数断言按此实测值填,见 Task 9)。
- [ ] Task 6 提交:`feat: record the reasoning verdict in telemetry`
---
## Task 7e2e 判据重建
**文件**:修改 `tests/e2e/test_thinking_live.py`
### 行为
`_run_rounds` 的逐轮观测字典增加两个键:`"thinking_observation": resp.thinking_observation``"thinking_chars": len(resp.thinking)`(报告里要能看见证据本身,而不只是结论)。
判据函数改写:
```python
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 观测到推理即为真。
判据从 `reasoning_tokens` 换成三态裁定,因为 MiniMax 这一路已不再上报
`completion_tokens_details`(2026-08-25 findings),而库在同一次调用里
拿得到 185 字符推理正文——旧判据看不见它,四条用例因此假红。
"""
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只要没观测到推理即算满足。
`UNKNOWN` 计入满足是有意的: 它没有证伪力(设计 §4.1),不能拿它判红。
本判据真正的证伪力在于——模型若偷偷推理了,可观测路径会翻成 OBSERVED。
"""
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
```
**删除 `_ON_MIN_COMPLETION` 常量及其全部引用**:两档 completion 分布实测重叠(关闭档最高 46、开启档最低 13),这个魔数退路从一开始就不成立。
**L5 重新定义**(当前实现断言"非流式开启档多数轮观测到推理",而 M3 非流式推理正文与 ctd 双缺,该断言永远不可能成立):改为断言两件真实成立的事——其一非流式下关闭档与开启档的 `prompt_tokens` 锚点仍然分开(证明参数确实到达模型,判据形态照抄 L2b);其二开启档观测为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。用例 docstring 写明:M3 非流式推理已计费却不回传正文,这是上游行为,库修不了但必须让它可见。
L3b 的 docstring 补一句不可移植性:minimax 对非法 `reasoning_effort` 返回 200 且照常推理,qwen 对同样的值返回 **HTTP 400**——该反证手法只对不校验值的 provider 成立。
模块顶部的判据纪律段与 `_write_report` 的报告表头同步改写为三态口径。
### 测试要求(先失败后通过)
本任务的证据是真跑:改前 `TestMiniMaxM3` 4 failed / 3 passed,改后全类 PASS。L5 的新断言在 Task 3 之前无法表达(字段不存在),是纯新增覆盖。
### 验证
```bash
conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v
```
预期:`TestMiniMaxM3` 7 passed;报告落 `tests/outputs/e2e/`。耗时约 7 分钟、约 137 次真实调用。
- [ ] Task 7 提交:`test: judge reasoning by what the library actually observed`
---
## Task 8:能力表 evidence 刷新
**文件**:修改 `src/polygateway/thinking.py`
### 行为
`DEFAULT_CAPABILITIES``MiniMax-M3``can_disable` **保持 `True`**2026-08-25 复测:`reasoning_effort=none` → prompt 194 = 基线、completion 3、无正文,声明依然成立)。`evidence` 追加复测日期与两条新限制:推理信号在非流式路径不可观测;`enable_thinking` / `thinking:{type:enabled}` 对该模型无效,仅 `reasoning_effort` 是真开关。
`minimax` profile 上方的注入形态注释同步补记复测日期。
### 测试要求
**先失败后通过不适用于本任务,理由须写进提交信息**:本任务只改 `evidence` 字符串与注释,`can_disable` 取值不变,**没有行为变更**,因而没有可先失败的行为断言(`test-driven-development` 的结果门约束的是行为变更)。声明依然成立这一事实,其证据是 2026-08-25 的复测与 Task 7 的 e2e 真跑,不是本任务能自造的单测。
`tests/unit/test_thinking.py` 既有的能力表用例(`evidence` 非空、`can_disable` 取值)须保持绿,作为回归证据。
### 验证
```bash
conda run -n PolyGateway pytest tests/unit/test_thinking.py -q
```
- [ ] Task 8 提交:`docs: refresh the M3 capability evidence with the 08-25 retest`
---
## Task 9:文档同步(构建前必须改完)
**文件**:修改 `README.md``research-wiki/ARCHITECTURE.md``research-wiki/schemas/llm-calls.md``research-wiki/index.md``CHANGELOG.md`
### 行为
**`README.md:21`**`必录 24 字段``25 字段`。数字取 Task 6 验证步骤里 `inspect.signature` 的实测输出,**不凭记忆**(发布清单第 1 步点名的失败模式)。同时核对安装命令的版本约束是否需要跟进,以及能力表是否要提及推理裁定这一新行为。
**`README.md``<!-- pg-template:table -->` 生产部署 DDL 模板**——**本条计划原文是错的,已订正**。
原文断言该模板是"独立于 `schema.py` 手写的另一份 SQL",要求补上 `thinking_observation TEXT`。**事实相反**:该模板不含任何列定义,它是 `CREATE TABLE llm_calls (LIKE llm_calls_seed INCLUDING DEFAULTS, PRIMARY KEY (call_id, created_at)) PARTITION BY RANGE (created_at)`,列全部从上一步 `telemetry_schema_sql('postgres')` 建出的 seed 表派生,README 正文原本就写着"列不在这里重抄一份——抄了就会漂移"。照原文补列会让 PG 报列重复、`TestProductionTemplate` 全红、下游部署直接失败。
(这条错误的来路值得记下来: 它出自另一个任务的实施报告,写进计划时**没有自己打开 README 核实**。跨任务转述的"发现"必须当作待验证的线索,不是事实。)
正确的做法是加一条**形态断言**: 模板必须靠 `LIKE` 派生,且不得内联任何 `COLUMNS` 里的列名。它钉住的是"日后有人把列抄进模板"这个真实风险——比原计划想堵的缺口更贴合实际。断言落在 `tests/integration/test_postgres_telemetry.py``TestProductionTemplate`**不在** `tests/unit/test_telemetry.py`,计划原文也指错了文件)。
**`research-wiki/ARCHITECTURE.md`**:§8 模块结构树补 `thinking.py` 一行并说明职责;§8 依赖纪律段补 `thinking.py` 的层位;D11 段说明推理决策已从 `providers.py` 拆出;§5.1 响应字段表补 `thinking_observation`;§7.8 遥测字段补新列。
**`research-wiki/schemas/llm-calls.md`**:标题与正文的"遥测 22 字段"已过期两轮,订正为 25;补 `thinking_observation` 的列定义与查询口径(示例:按模型统计各观测态占比,用于发现某模型何时开始观测不到推理)。
**`research-wiki/index.md`**:登记本 plan、design 与 finding。
**先失败后通过不适用于本任务**:纯文档同步,无行为变更。其验收是下方 grep 的可见输出——数字与模块名对不上就是没改完。
**`CHANGELOG.md`**:新增 1.3.1 条目。**断裂项置于条目最前**,沿用 1.3.0"请先读这一条"体例(设计 §13:版号既然不承担预警职责,预警由 CHANGELOG 独立扛)。三条必须显式列出——① `polygateway.providers` 的深路径 import 断裂(`ThinkingCapability` / `resolve_thinking` / `get_capability` / `register_capability` / `DEFAULT_CAPABILITIES` / `ThinkingUnsupportedError` 移入 `polygateway.thinking`,同时提升到包根,**推荐改用包根 import**);② `TelemetryRecorder.record_llm_call` 端口签名 24 参 → 25 参,自定义 recorder 实现须同步;③ M3 非流式开启推理时推理内容已计费却不回传,该档观测为 `UNKNOWN`,库现在会告警一次。
### Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan --id 2026-08-25-thinking-observability-plan --title "推理可观测性一等化实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ --from "plan:2026-08-25-thinking-observability-plan" --to "design:2026-08-25-thinking-observability-design" --type implements --evidence "本计划实现该设计的全部落点"
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
### 验证
```bash
grep -n '25 字段' README.md
grep -n 'thinking.py' research-wiki/ARCHITECTURE.md
grep -rn '22 字段' research-wiki/schemas/llm-calls.md # 预期无输出
```
- [ ] Task 9 提交:`docs: sync the field counts and module map to 1.3.1`
---
## Task 10:合并前独立验证与发布 1.3.1
**文件**:修改 `pyproject.toml``src/polygateway/__init__.py`
### 行为
版本号两处改 `1.3.1``pyproject.toml``__init__.py.__version__` 必须一致);`CHANGELOG.md` 的"未发布"定版为 `## 1.3.1(2026-08-25)`
本任务分两段,**中间是一道人类确认门**。
**第一段:分支内可自主完成的验证**——CHANGELOG 定版为 `## 1.3.1(2026-08-25)`;版本号两处改 `1.3.1``verification-before-completion` 派**全新上下文** verifier subagent 独立验证(跨 20+ 文件,属强制档);`requesting-code-review` 整分支审查;在分支上跑 `make ci``pytest -m slow`(约 20-40 分钟——四个 e2e 文件与 Redis 时间语义变体默认被 `-m 'not slow'` 排除,不显式跑等于没跑)。
**Gitea Wiki 文档站同步**(计划原本漏了,Task 9 实施时发现):`research-wiki/docs-convention.md` §2 明写"新公共 API / 新能力 → 对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG"、"发版(任何版本号) → `Home.md` 版本号与安装命令",且该文件第 26 行是一道门——**版本 bump 的提交不允许单独存在**。本版有 6 个新包根导出、1 个新公共字段、1 个端口签名变更,wiki 必须同步。wiki 是**独立 git 仓库**(需 clone),故拆成两半:**内容在第一段写好待推**,`git push` 归第二段(外发动作)。
**人类确认门**:以上全绿后停下,把验证结果交给人类,**取得明确同意后**才执行第二段。
**第二段:外发且难以撤销的动作,一律等确认**——合并 main(`--no-ff`+ push → 打 tag 并 push → 构建 → 上传 registry → `pip download` 验证并解包确认新代码在内 → 建 Release + 挂仓库 + 核对包页面 → 关闭 issue #16 / #17 并附修复说明(诊断纠正 + 三层根因 + 落地形态)。顺序按 CLAUDE.md §4.4.1**不得跳步**:包上传与 tag 一旦推出去就收不回,registry 里的版本号也不能复用。
合并到 main 后须在 main 上**重跑** `make lint` 与全套件外加 `pytest -m slow`——分支上跑过不算,合并本身可能引入差异。
### 验证
```bash
conda run -n PolyGateway make ci
conda run -n PolyGateway pytest -m slow
python -c "import tomllib,pathlib,re
v=tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
i=re.search(r'__version__ = \"(.+?)\"', pathlib.Path('src/polygateway/__init__.py').read_text()).group(1)
assert v == i == '1.3.1', (v, i); print('版本号一致:', v)"
```
预期:`make ci` 绿;slow 全绿;版本号一致性检查通过。
- [ ] Task 10 提交:`chore: cut 1.3.1`
---
## 任务依赖
Task 1 → 2 → 3 是硬序(枚举 → 模块就位 → 字段贯通)。Task 4、5、6 都依赖 3,彼此独立可并行。Task 7 依赖 3(需要字段)。Task 8 依赖 2(能力表已搬)。Task 9 依赖 6(字段数实测值)。Task 10 最后。
## 全局纪律
不做计划外的重构与抽象——尤其**不重构遥测组装路径**:`TelemetryEmitter._record` 已经是铁律要求的单一出口,三个 `emit_*` 是三个语义不同的入口,各自组装参数是职责所在(设计 §12)。
每个任务独立提交,提交前跑该任务的验证命令。任何一步的完成声明必须对应本会话内的工具输出。
@@ -0,0 +1,297 @@
---
type: plan
node_id: plan:2026-08-26-issue18-pg-test-isolation
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
date: 2026-08-26
---
# issue #18 实现计划
> 类型:plan|日期:2026-08-26|分支 `fix/issue-18-pg-test-isolation`
> 实现设计 `designs/2026-08-26-issue18-pg-test-isolation-design.md`(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。
> **本计划不涉及参考实现迁移,保真校验不适用。**
> [!CAUTION]
> **执行期唯一的不可逆风险,写在最前面。** 设计 §5.3 的"最坏情况"用例故意让脚本以裸 `search_path` 跑到共享表上。它**只有在沙箱角色就位之后才可以跑**——若在角色化之前用 `.env``app`(实测 superuser)跑它,`--older-than-days 7 --apply` 会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。
> 这条风险决定了下面的任务顺序:**沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例**。它没有常规意义上的"先红"路径,见 Task 2 的说明。
## 目标
`tests/integration` 不再依赖也不再污染共享表 `llm_calls`,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
## 方案概述
先建 `tests/integration/conftest.py` 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 `telemetry_retention.py``--table SCHEMA.llm_calls`(目标由参数精确解析、绕开 `search_path`,表名段锁死);随后把 `test_postgres_telemetry.py` 的 7 条用例迁出真表、拆分 `_RUN_PREFIX` 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
## 涉及技术
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 `search_path` 语义 / argparse。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `tests/integration/conftest.py` | **新建** | `PgSandbox``pg_sandbox` 工厂;admin DSN 私有化 |
| `tests/integration/test_pg_sandbox.py` | **新建** | 工厂自身的行为测试(含 setup 中途失败不留残留) |
| `tests/integration/test_retention_tool_pg.py` | 修改 | 全部用例角色化;删行数快照;补 `--table` 与最坏情况用例 |
| `tools/telemetry_retention.py` | 修改 | 新增 `--table`;PG 分支目标解析改为"显式限定名优先" |
| `tests/unit/test_retention_tool.py` | 修改 | `--table` 的参数分类用例(不连库) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 7 条用例迁出真表;`_RUN_PREFIX` 双职责拆分;其余 fixture 收敛到工厂 |
| `Makefile` | 修改 | `lint` / `check` 各加一道字面量门 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` | 修改 | `--table` 用法与 1.3.2 定版 |
---
## 跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
`tests/integration/conftest.py` 对外只有一个 fixture 与一个返回类型:
```python
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str # 已挂 options=-csearch_path=<schema>
bare_dsn: str | None # 同角色但不挂 search_path;role is None 时为 None
```
```python
async def pg_sandbox(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: Literal["none", "owner", "grantee"] = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox: ...
```
### 三种 `role` 的语义
覆盖现有全部六个 fixture 的需求,**不得再加第四种**:
| `role` | schema 属主 | `ddl`/`extra` 由谁执行 | 返回 DSN 的身份 | 对应今天的 fixture |
|---|---|---|---|---|
| `"none"` | admin | admin | admin | `fresh_schema` / `legacy_schema` / `pre_tenant_schema` / `partitioned_schema` |
| `"owner"` | 临时角色 | **临时角色自己**(故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
| `"grantee"` | admin | **admin**(故表属主 = admin,与最小权限现场一致) | 临时角色(只被 `GRANT USAGE ON SCHEMA` + 表级 `grants`**绝不 GRANT CREATE** | `least_privilege_dsn` / `least_privilege_pre_tenant_dsn` |
### `ddl` / `extra` 的执行契约
1. **调用方传的 DDL 一律不带 schema 限定**`CREATE TABLE llm_calls (...)`,不是 `CREATE TABLE {schema}.llm_calls`)。工厂在执行前对该连接 `SET search_path = <schema>`,由 search_path 定位。这条统一了两种今天并存的写法——`PG_DDL` 本就是裸表名,而 `_LEGACY_DDL` / `_PRE_TENANT_DDL` 今天带 `{schema}` 占位,**Task 5 要把这两个常量的 `{schema}.` 前缀去掉**。
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
3. `ddl is None` 时只建空 schema,不执行任何建表语句。
### 临时角色的 DSN 构造
- 密码:模块级常量(测试专用,非机密),沿用今天 `_PROBE_PASSWORD` 的做法。
- `bare_dsn`:把 admin DSN 里的 `//user:pass@` 段整体替换为 `//<role>:<密码>@``re.sub(r"//[^@/]+@", ...)``count=1`),**不追加任何 `options` 参数**——它的用途就是让 `search_path` 回落到 `"$user", public`
- `dsn`:在 `bare_dsn` 基础上追加 `options=-csearch_path%3D<schema>`,分隔符按 DSN 里是否已有 `?``?``&`
- `role="none"``dsn` 用 admin 身份加同样的 options`bare_dsn``None`——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
### 三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
1. schema 名 `pgw_s_<12 位 hex>`、角色名 `pgw_r_<12 位 hex>`,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`except BaseException` 时**逆序**执行并 re-raise`yield` 之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为 `DROP SCHEMA IF EXISTS <s> CASCADE``DROP OWNED BY <r>``DROP ROLE IF EXISTS <r>``DROP OWNED BY` 必须在 `DROP ROLE` 之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。
3. `role != "none"` 时先查 `rolcreaterole OR rolsuper`**在建任何对象之前** `pytest.skip``production_template` 的教训:`pytest.skip` 抛的是 `BaseException`,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
---
## Task 1:沙箱工厂
- [ ] **文件**`tests/integration/conftest.py`(新建)、`tests/integration/test_pg_sandbox.py`(新建)
**行为**:实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(`dotenv_values(".env")` 合并 `os.environ`,剥掉 `+driver`,缺则 `skip`,库名不以 `/polygateway` 结尾则 `pytest.fail`)——这段逻辑今天重复两份,本任务收敛为一份私有函数。
**测试要求(先红后绿的路径明确)**:先写 `test_pg_sandbox.py` 再写 `conftest.py`——此时 `pg_sandbox` fixture 不存在,pytest 报 `fixture 'pg_sandbox' not found`,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
| 用例 | 断言 |
|---|---|
| `role="none"` 建表 | 表落在 `sandbox.schema` 下;`sandbox.bare_dsn is None` |
| `role="owner"` 建表 | 表属主 = `sandbox.role``sandbox.role != sandbox.schema` 且两者前缀不同 |
| `role="owner"``bare_dsn` | `SHOW search_path``"$user", public`;用它解析 `llm_calls` 得到的**不是**沙箱里那张表 |
| `role="grantee"` | 该角色 `CREATE TABLE` 被拒(`asyncpg.exceptions.InsufficientPrivilegeError`),`INSERT` 正常 |
| **setup 中途失败** | 传一段必然报错的 `ddl`(如 `CREATE TABLE llm_calls (bad_type NOT_A_TYPE)`),捕获异常后查 `pg_namespace` / `pg_roles`:本次 uuid 对应的 schema 与角色**都不存在** |
| teardown 后无残留 | 在用例内部记下 `sandbox.schema` / `sandbox.role`,用一个**更外层**的 fixture(在 `pg_sandbox` 之后销毁)回查两者均已消失 |
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
```
预期全绿;随后手工查实例:`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'``pg_roles` 同款查询均为空。
---
## Task 2retention 测试角色化,删除行数快照
- [ ] **文件**`tests/integration/test_retention_tool_pg.py`(修改)
**必须在 Task 3 之前完成**——见文首 CAUTION。
**行为**
1. 删除 `_public_count``before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox`
2. **凡启动脚本的用例一律 `role="owner"`**(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5``'acme': 3`、批次 1/3 存在而批次 4 不存在、`已删除 5 行`、剩余 `fresh-1`/`fresh-2`、分区表退出 3 且含 `DROP PARTITION`/`DETACH`、缺 asyncpg 退出 2。
4. 新增设计 §5.3 的**最坏情况**用例:用 `sandbox.bare_dsn`、不给 `--table``--older-than-days 7 --apply`。断言退出 **2**、stderr 非空且含 `llm_calls`、沙箱表一行不少。**不断言 PG 的英文错误原文**(`lc_messages` 不由测试掌握),**测试代码里不得出现 `public.llm_calls` 字面量**。
**测试证据(这条用例没有常规先红路径,如实记录)**:让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 `findings/2026-08-26-issue18-shared-pg-test-isolation.md` §7 的探针 6/7 提供:同款临时角色对真表的 `COUNT``DELETE` 均返回 `InsufficientPrivilegeError`。**提交说明里必须写明这一点**,不得含糊成"已验证"。
其余改动的先红路径正常:删掉 `_public_count` 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
```
预期全绿;连跑三次结果一致。
---
## Task 3`--table` 参数与精确解析
- [ ] **文件**`tools/telemetry_retention.py`(修改)、`tests/unit/test_retention_tool.py`(修改)、`tests/integration/test_retention_tool_pg.py`(追加用例)
**顺序**:**先写测试再改脚本**——四条集成用例与五条单测在脚本未改时全部先红(`--table` 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
**脚本行为**(设计 §4):
| 项 | 要求 |
|---|---|
| 参数 | `--table SCHEMA.NAME`,仅 `--backend postgres` 接受 |
| 校验(全部退出 **1**) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 `.``"`**表名段不等于 `llm_calls`** |
| 解析 | 给了 `--table` 时用 `to_regclass($1)``"<schema>"."llm_calls"``_quote` 包裹),绕开 `search_path`;未给时维持今天的裸 `TABLE` 解析 |
| 解析不到 | 退出 **2**,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
| 无权限 | 后续 `COUNT``PostgresError`,走既有 except → 退出 **2**(不新增分支) |
| 分区表 | 仍退出 **3**,逻辑不动 |
| 提示行 | `--apply` 且**未**给 `--table` 时,在"目标表: x.y"之后打印一行,指出目标由 `search_path` 推断、可用 `--table` 钉死;dry-run 不打 |
`--help` 的 epilog 补两句:本脚本只清理 `llm_calls`;含点或引号的复杂标识符不支持,此时退回不给 `--table` 的路径。
**单测**`tests/unit/test_retention_tool.py`,不连库):`TestUsageErrors` 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 `--table``TestHelp` 加一条断言 epilog 点明表名固定为 `llm_calls`
**集成用例**`test_retention_tool_pg.py`,全部 `role="owner"`):
| 用例 | 构造 | 预期 |
|---|---|---|
| 显式指定成功 | `--table <sandbox.schema>.llm_calls` + `--apply` | 退出 0,删除结果与不给 `--table` 时逐条一致 |
| 指向不存在的 schema | `--table pgw_s_nosuchxxxxxxxx.llm_calls` | 退出 **2**,stderr 点名该表;沙箱表一行不少 |
| 指向无权的表 | 建两个 `role="owner"` 沙箱,用 A 的 DSN 指 B 的表 | 退出 **2**A、B 两张表都不变 |
| 指向分区表 | 分区沙箱 + `--table` | 仍退出 **3**,含 `DROP PARTITION` / `DETACH` 字样 |
| 提示行(设计验收 #2 | 沙箱 DSN + `--apply`**不给** `--table` | stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
**验证**
```
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
```
---
## Task 4:7 条用例迁出真表,`_RUN_PREFIX` 拆职责
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. 七条用例改用 `pg_sandbox(role="none")``TestObservabilityColumns::test_values_round_trip``TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows``test_aclose_idempotent``TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max`
2. `test_schema_has_frozen_columns_in_order``information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")``"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
**测试证据**:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)`),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
```
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
---
## Task 5:其余 fixture 收敛到工厂
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. `legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema` 改为 `role="none"``least_privilege_dsn``least_privilege_pre_tenant_dsn` 改为 `role="grantee"`
2. 按接口契约,`_LEGACY_DDL``_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
3. `production_template` **不收敛**:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
**测试证据**:这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
**验证**:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
---
## Task 6lint 门与字面量清理
- [ ] **文件**`Makefile`(修改)、`tests/integration/*.py`(注释措辞)
**行为**`lint``check` 各加一步——`tests/` 下命中字面量 `public.llm_calls``exit 1` 并打印命中行。注释与 docstring **同样不豁免**,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls`"。
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
**测试证据**:故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
**验证**
```
make lint && make check
```
---
## Task 7:独立验证(合并前硬门)
- [ ] 派**全新上下文**的 verifier subagent`verification-before-completion`),交给它设计 §11 的判据表逐条核对,重点:
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
- 判据 6:7 条用例迁移前后断言逐条对照
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
---
## Task 8:文档与版本号
- [ ] **文件**`README.md``CHANGELOG.md``pyproject.toml``src/polygateway/__init__.py`
- README`--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls`。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
- CHANGELOG:按设计 §10 如实写明 `tools/``tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
- 版本号两处一致改 `1.3.2`
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
---
## Task 9:发布 1.3.2
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff`)→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow`** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registrytoken 走 `TWINE_PASSWORD`,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
- [ ] 关闭 issue #18,正文指向本计划与设计。
---
## 审查留痕(Codex2026-08-26
报 5 项,**全部采纳**
| # | 意见 | 处置 |
|---|---|---|
| 1 | `ddl`/`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl`/`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table`(测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py`,此时 `fixture 'pg_sandbox' not found` 全红 |
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
## Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ \
--from "plan:2026-08-26-issue18-pg-test-isolation" \
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
@@ -0,0 +1,418 @@
# 实现计划: 推理档位一等化
- **设计**: `research-wiki/designs/2026-09-04-reasoning-effort-design.md`(2026-09-04 人类已批准)
- **目标**: 把 `enable_thinking: bool | None` 升级为可表达厂商档位的 `Effort` 词汇,让「关不掉的模型」「打空的档位」从静默失效变成带出路的报错。
- **方案概述**: 新增八档封闭枚举 `Effort`(含 `auto`);能力表从 `can_disable: bool` 改为 `supported_efforts: tuple[Effort, ...]`;provider 的两个固定片段改为 `ThinkingWire`(off / on_base / effort_key);档位入口取「源级默认 + 请求级覆盖」,进缓存 key 与遥测各一列。
- **涉及技术**: Python 3.12 `StrEnum`、frozen dataclass、pydantic-settings env 解析、SQLite/Postgres DDL 补列、pytest。
- **保真校验**: **不适用**。本计划实现的是库自研的推理决策(`thinking.py` 系 2026-08-25 新建),不属 ARCHITECTURE §1.4 的移植蓝本;且 `reference/` 三项目当前不在工作区(见设计 §12),无可比对源。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | 新增 `Effort` 枚举;`SourceConfig`/`ChatRequest` 各加档位字段 |
| `src/polygateway/thinking.py` | 修改 | `ThinkingCapability` 重构、`resolve_thinking` 五关、`reconcile_thinking` 判据、默认能力表重写 |
| `src/polygateway/providers.py` | 修改 | `ThinkingWire` 新类型替换两个片段;`DEFAULT_PROFILES` 扩到 8 段 |
| `src/polygateway/config.py` | 修改 | 两个新 env 键的解析与矛盾校验 |
| `src/polygateway/client.py` | 修改 | `chat()` 签名加档位;`_fingerprint_mark` 纳入源级档位 |
| `src/polygateway/middleware/cache.py` | 修改 | `build_cache_key` 纳入请求级档位 |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 与三个 emit 入口传递生效档位 |
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 加一参(25 → 26 字段) |
| `src/polygateway/telemetry/schema.py` | 修改 | `COLUMNS`、两端 DDL、补列声明 |
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 修改 | 落库新列 |
| `src/polygateway/transports/openai_compat.py` | 修改 | 生效档位解析接线、告警节流键 |
| `src/polygateway/__init__.py` | 修改 | 导出 `Effort``ThinkingWire` |
| `.env.example` | 修改 | 两个新键的模板与注释 |
| `tests/unit/test_thinking.py` | 修改 | 位置参数构造迁移 + 五关用例 |
| `tests/unit/test_providers.py` | 修改 | `ThinkingWire` 用例 |
| `tests/unit/test_cache.py` | 修改 | 档位进 key 的用例 |
| `tests/unit/test_openai_compat.py` | 修改 | transport 接线与节流用例 |
| `tests/unit/test_telemetry.py``tests/integration/test_redis_cache.py` | 修改 | 列数断言与缓存 key 回归 |
| `tests/e2e/test_thinking_live.py` | 修改 | `can_disable` 读法迁移;新增逐模型档位实测(标 `slow`) |
---
## 关键接口(跨任务消费,此处定稿)
```python
# types.py
class Effort(StrEnum):
NONE = "none"; AUTO = "auto"; MINIMAL = "minimal"; LOW = "low"
MEDIUM = "medium"; HIGH = "high"; XHIGH = "xhigh"; MAX = "max"
_ORDER = (Effort.NONE, Effort.MINIMAL, Effort.LOW, Effort.MEDIUM,
Effort.HIGH, Effort.XHIGH, Effort.MAX) # auto 不参与强弱序
```
```python
# providers.py
@dataclass(frozen=True)
class ThinkingWire:
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
name: str
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
```
```python
# thinking.py
@dataclass(frozen=True)
class ThinkingCapability:
supported_efforts: tuple[Effort, ...]
evidence: str
@property
def can_disable(self) -> bool: ... # Effort.NONE in supported_efforts
@property
def cheapest_effort(self) -> Effort | None: ... # 除 NONE 外按 _ORDER 最弱的一档
@property
def is_tiered(self) -> bool: ... # 除 NONE/AUTO 外仍有 ≥1 档
@dataclass(frozen=True)
class ThinkingResolution:
"""注入片段 + **实际**生效档。
返回 dataclass 而非裸 Mapping(CLAUDE.md 4.3「返回类型用 frozen dataclass」):
`nearest` 映射后请求档与实际档不同,遥测必须记后者,否则 T10 的压测按档位
分组时,被映射过的行会挂在一个从未真正发出的档下(Codex 审查指出)。
"""
payload: Mapping[str, Any]
applied_effort: Effort | None # Phase 1(不表态)为 None
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | None,
*,
model: str,
fallback: str = "error", # "error" | "nearest"
warn_unregistered: bool = True,
) -> ThinkingResolution: ...
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None: ...
```
```python
# types.py 字段追加(均追加在末尾,不扰动既有位置构造)
# SourceConfig: reasoning_effort: Effort | None = None
# effort_fallback: str = "error"
# ChatRequest: reasoning_effort: Effort | None = None
```
---
## Task 1 — `Effort` 词汇与能力表重构
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_thinking.py`(改)、`tests/e2e/test_thinking_live.py`(改)
**行为**:
1. `types.py` 新增 `Effort``_ORDER`(见上)。放 `types.py` 而非 `thinking.py`: 它是 `SourceConfig`/`ChatRequest` 的字段类型,定义在决策模块会让 `types.py` 反向 import(依赖铁律)。
2. `ThinkingCapability` 改为 `supported_efforts` + `evidence`,加两个 `@property` 派生量。构造期校验: `supported_efforts` 非空、元素唯一、全部属 `Effort`,违反即 `ValueError`
3. `DEFAULT_CAPABILITIES` 按设计 §8 落库规则重写(见下表)。
4. 迁移三处既有读点: `thinking.py` 内部读 `capability.can_disable` 改为读派生属性(行为不变);`tests/unit/test_thinking.py``ThinkingCapability(True, "实测")` 位置参数构造改为关键字构造;`tests/e2e/test_thinking_live.py``can_disable` 处确认派生属性可用。
5. `__init__.py` 导出 `Effort`(包根导出是既有纪律: 深路径 import 正是模块重组会打断下游的原因,见 ARCH D11)。
**初始 `DEFAULT_CAPABILITIES`**(evidence 一律以 `2026-09-04 文档推定(来源),待经 new-api 实测` 开头):
| model | supported_efforts |
|---|---|
| `glm-5.3`, `glm-5.3-flash` | `(LOW, HIGH, MAX)` |
| `glm-5.2` | `(NONE, HIGH, MAX)` |
| `glm-5`, `glm-5.1`, `glm-4.6v` | `(NONE, AUTO)` |
| `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | `(NONE, HIGH, MAX)` |
| `gpt-5.4`, `gpt-5.5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH)` |
| `claude-opus-5`, `claude-sonnet-5` | `(NONE, LOW, MEDIUM, HIGH, XHIGH, MAX)` |
| `gemini-3.1-pro` | `(LOW, MEDIUM, HIGH)` |
| `kimi-k3` | `(LOW, HIGH, MAX)` —— 保守登记,evidence 注明 OpenRouter 标可关但官方档位无 `none` |
| `MiniMax-M3` | `(NONE, AUTO)` |
| `MiniMax-M2.7`, `MiniMax-M2.5` | `(AUTO,)` |
| `qwen-plus-latest`, `qwen3.5-flash`, `qwen3.6-plus`, `qwen3.7-max`, `qwen3.7-plus` | `(NONE, AUTO)` |
`claude-haiku-5``gemini-3-flash``kimi-for-coding` **不登记**(档位清单未知,走 Phase 3)。现有三条 MiniMax 条目的 evidence 原文保留并追加新形状说明——它们是实测得来的,比文档推定更硬,不得覆盖。
**验收**: `can_disable` 对 11 类模型的返回与上表一致;`cheapest_effort``(LOW, HIGH, MAX)` 返回 `LOW`、对 `(NONE, AUTO)` 返回 `AUTO`、对 `(AUTO,)` 返回 `AUTO`;`is_tiered``(LOW, HIGH, MAX)` 为真、对 `(NONE, AUTO)``(AUTO,)` 为假;空元组构造报 `ValueError`
**测试**(先失败后通过): `tests/unit/test_thinking.py::test_capability_derives_can_disable``::test_cheapest_effort_skips_none``::test_is_tiered_excludes_none_and_auto``::test_empty_efforts_rejected`
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py tests/unit/test_package.py -v` → PASS;`conda run -n PolyGateway make lint` → PASS(含 import-linter: `Effort``types.py` 不得产生反向依赖,设计 §13 第 6 条)
- [ ] 提交: `refactor: make capability a tier list, since "can it be off" is one entry in it`
---
## Task 2 — `ThinkingWire` 与 8 段 provider 表
**文件**: `src/polygateway/providers.py`(改)、`src/polygateway/__init__.py`(改)、`tests/unit/test_providers.py`(改)
**行为**:
1. 新增 `ThinkingWire`(见关键接口)。`None` 的语义严格沿用 issue #5: `on_base is None` = **开启形态未知**(请求开启档时报错),`off is None` = 该 provider 无关闭形态,`effort_key is None` = 该 provider 无档位概念。三者语义互不重叠,docstring 必须写明。
2. `ProviderProfile.thinking_on`/`thinking_off` 两字段替换为 `thinking: ThinkingWire`
3. `DEFAULT_PROFILES` 由 4 段扩到 8 段:
| provider | off | on_base | effort_key |
|---|---|---|---|
| `qwen` | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None` |
| `deepseek` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `zhipu` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `moonshot` | `{"thinking": {"type": "disabled"}}` | `{"thinking": {"type": "enabled"}}` | `"reasoning_effort"` |
| `minimax` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `openai` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `anthropic` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
| `google` | `{"reasoning_effort": "none"}` | `{}` | `"reasoning_effort"` |
`__init__.py` 同步导出 `ThinkingWire``openai` 段的两档由 `None`(未知)改为 OpenAI 标准形态,是本任务唯一的语义变更,理由写进注释: gpt-5.x 的 `reasoning_effort` 是 OpenAI 官方字段而非厂商方言,兜底段发它不会打到不认识它的厂商;真正未知形态的 provider 仍应走 `register_provider`
**验收**: `get_provider("zhipu").thinking.effort_key == "reasoning_effort"`;未注册名仍报错且错误文案列出全部 8 段;`register_provider` 仍返回新表不改共享状态。
**测试**(先失败后通过): `tests/unit/test_providers.py::test_all_eight_profiles_registered``::test_wire_none_semantics_distinct`(三种 `None` 各自的含义不混淆)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS
- [ ] 提交: `feat: give zhipu, moonshot, anthropic and google a wire of their own`
---
## Task 3 — `resolve_thinking` 五道关卡与 nearest 映射
**文件**: `src/polygateway/thinking.py`(改)、`tests/unit/test_thinking.py`(改)
**行为**: 按下表实现,**顺序不可调换**,每关的理由写进 docstring。
| Phase | 条件 | 结果 |
|---|---|---|
| 1 | `effort is None` | 返回 `{}` |
| 2 | **该请求档所需的**形态未知(请求 `none``wire.off`,其余档看 `wire.on_base`) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | `capability is None` | `warn_unregistered` 为真时 warning,随后按 wire 注入,**不校验档位** |
| 4 | `effort is NONE``not capability.can_disable` | `ThinkingUnsupportedError`,文案含 `cheapest_effort` 与 env 键名 |
| 5 | `effort not in supported_efforts``fallback == "error"` | `ThinkingUnsupportedError`;文案按 `capability.is_tiered` 分叉——档位型列出可选档,纯开关型说明「该模型只有开关没有档位,可用 `auto`/`none`」(设计 §3.2 第三个派生量的用途) |
Phase 4 必须先于 5: `none` 只是 5 的特例,落进 5 会退化成「不支持 none,可选 low/high/max」,丢掉「这个模型根本关不掉」与可执行替代。
**注入形态**:
- `effort is NONE``wire.off`;`wire.off is None` 时报错(该 provider 无关闭形态)。
- `effort is AUTO``wire.on_base`(不附档位)。这与旧 `thinking_on` 逐字节等价。
- 其余档 → `{**wire.on_base, wire.effort_key: effort.value}`;`effort_key is None` 时报错并说明该 provider 只有开关没有档位。
**nearest 映射**(`fallback == "nearest"`,人类 2026-09-04 复核确认实现): 按 `_ORDER``supported_efforts` 中取距请求档**位序最近**者,等距时**取弱侧**(省钱优先,不替下游涨价);`AUTO` 不参与距离计算,仅当它是唯一候选时才被选中;映射发生时 warning 记明「请求档 → 实际档 → 模型」。`effort is NONE` 且不可关时**不走映射**——那是 Phase 4 的领域,必须报错给出路,否则又变成静默降级。
**验收**: 五关各自触发与不触发;`medium``(LOW, HIGH, MAX)``nearest` 映射到 `LOW`(等距取弱);`minimal` 映射到 `LOW`;`xhigh` 映射到 **`HIGH`**(与 `MAX` 等距,按「等距取弱」规则走——初稿此处写 `MAX` 是笔误,规则优先于例子)。**候选剔除 `none`**: 否则 `(none, auto)` 模型上请求 `high` 会被映射成 `none`,把「想浅一点」变成「别想了」,方向反转即 issue #20 那类静默失效。**`auto` 不受 Phase 5 清单约束**: 它在请求体里是「不写 `effort_key`」而非某个取值,可满足性只取决于 `on_base` 在不在;否则 `enable_thinking=True → AUTO` 会让存量源当场报错(能力表里档位型模型都不含 `auto`)。
**测试**(先失败后通过,**五关各一条**,兑现设计 §13 第 1 条): `::test_phase1_absent_effort_injects_nothing``::test_phase2_unknown_wire_points_to_register``::test_phase3_unregistered_warns_then_injects`(并断言 `warn_unregistered=False` 时不喊)、`::test_phase4_before_phase5`(请求 `none` 打到 glm-5.3,断言文案**含** `cheapest_effort` 值与 `REASONING_EFFORT` 键名)、`::test_phase5_lists_tiers_for_tiered_model``::test_phase5_says_toggle_only_for_switch_model`
另: `::test_nearest_ties_go_cheaper``::test_none_never_maps``::test_auto_injects_on_base_only``::test_effort_key_none_rejects_tier``::test_resolution_reports_applied_effort_after_mapping`(请求 `medium` → 断言 `applied_effort is Effort.LOW`)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_thinking.py -v` → PASS
- [ ] 提交: `feat: refuse an impossible tier with the cheapest one that model does have`
---
## Task 4 — 源级配置入口
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/config.py`(改)、`.env.example`(改)、`tests/unit/test_config.py`(改)
**行为**:
1. `SourceConfig` 末尾追加 `reasoning_effort: Effort | None = None``effort_fallback: str = "error"`
2. `config.py``_SOURCE_FIELDS` 增两行: `"REASONING_EFFORT": ("reasoning_effort", "effort")``"EFFORT_FALLBACK": ("effort_fallback", "str")`。新增 `"effort"` 解析类型: 值必须属 `Effort` 取值域,否则报错并列出八档。
3. `effort_fallback` 值域 `{"error", "nearest"}`,越界即报错(与 `_SELECTORS`/`_QUOTA_FULL` 同款 frozenset 校验)。
4. **矛盾校验**(构造期): 同源同时给出 `enable_thinking``reasoning_effort` 且语义冲突时 `ValueError`。冲突定义: `enable_thinking is True``reasoning_effort is NONE`;或 `enable_thinking is False``reasoning_effort not in (None, Effort.NONE)`。二者一致(如 `False` + `none`)则放行。
5. `.env.example` 加两键模板,注释写明八档取值、与 `ENABLE_THINKING` 的等价关系及矛盾会报错。
**验收**: `LLM__ZHIPU__1__REASONING_EFFORT=low` 解析为 `Effort.LOW`;写 `lowest` 报错且文案列出八档;`ENABLE_THINKING=true` + `REASONING_EFFORT=none` 构造期报错。
**测试**(先失败后通过): `tests/unit/test_config.py::test_effort_key_parsed``::test_invalid_effort_lists_vocabulary``::test_contradictory_thinking_flags_rejected``::test_consistent_flags_allowed`
**验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py -v` → PASS
- [ ] 提交: `feat: let a source name its reasoning tier, and say so when it contradicts itself`
---
## Task 5 — 请求级入口与优先级
**文件**: `src/polygateway/types.py`(改)、`src/polygateway/thinking.py`(改,`effective_effort` 定义处)、`src/polygateway/client.py`(改)、`tests/unit/test_client.py`(改)
**行为**:
1. `ChatRequest` 末尾追加 `reasoning_effort: Effort | None = None`
2. `GatewayClient.chat()` 增关键字参数 `reasoning_effort: Effort | None = None`,存入 `ChatRequest`
3. 新增纯函数(放 `thinking.py`,与其余推理决策同处):
```python
def effective_effort(
*, request_effort: Effort | None, source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""生效档位: 请求级 > 源级 > enable_thinking 语法糖 > None。"""
```
语法糖映射: `True``Effort.AUTO`(注入 `on_base`,与旧行为逐字节等价,且不依赖能力表);`False``Effort.NONE`;`None` → 不表态。
**验收**: 三层优先级各自生效;请求级 `None` 不会覆盖源级已配的档;只配 `enable_thinking=True` 的存量源解析为 `AUTO` 且最终 payload 与升级前逐字节相同。
**测试**(先失败后通过): `::test_request_effort_wins_over_source``::test_none_request_does_not_clear_source``::test_enable_thinking_true_is_auto``::test_legacy_on_tier_matches_old_fragment`(回归门: **仅**对 `on_base` 完整表达「开」的 provider——qwen/deepseek/zhipu/moonshot——断言逐字节不变;minimax/openai/anthropic/google 的开档旧版硬编码 `medium`、新版不注入,是设计 §4.2 声明过的有意变更)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
- [ ] 提交: `feat: let one call ask for a different tier than its source defaults to`
---
## Task 5b — 让 transport 拿得到请求级档位(端口签名扩展)
**文件**: `src/polygateway/ports.py`(改)、`src/polygateway/middleware/retry.py`(改)、`src/polygateway/transports/openai_compat.py`(改)、`tests/unit/test_retry.py`(改)、`tests/unit/test_backpressure.py`(改)、`tests/integration/test_redis_cross_connection.py`(改)、`tests/unit/test_ports.py`(改)
**为什么单列一步**(Codex 审查查出的阻断问题): T5 只把 `reasoning_effort` 放进 `ChatRequest`,但 `Transport` 协议收的是**拆开的**参数(`messages/source/stream/overlay/call_id`,`ports.py:39-49`),`RetryMW._attempt` 也只传这五个(`retry.py:282-288`)。不扩展协议,请求级档位根本到不了 `_build_payload`,设计 §4.2 的优先级落不了地。
**行为**:
1. `Transport.complete` 协议增关键字参数 `reasoning_effort: Effort | None`。**不设默认值**——与 `TelemetryRecorder` 同一既有约定: 库外无第三方实现者,完整签名成本为零,而给默认值会让漏传变成静默的「不表态」。
2. `RetryMW._attempt` 调用处传 `request.reasoning_effort`。该中间件此前只读 `request` 的五个字段,新增第六个,不改其他语义。
3. `OpenAICompatTransport.complete` 接收并透传给 `_build_payload`
4. 三个测试 fake 同步扩签名(`tests/unit/test_retry.py:72``tests/unit/test_backpressure.py:213``tests/integration/test_redis_cross_connection.py:76`)——`@runtime_checkable` 只查方法名不查签名,漏改会在调用时 `TypeError`,且错误现场离根因很远。
**不动**: `EmbeddingTransport``OcrTransport` 两个协议——它们无推理语义(与 issue #4 给 embedding 加 `extra_body` 被否决同理: 装配期报错比静默无效更能指路)。
**验收**: 请求级档位能一路到达 `_build_payload`;三个 fake 与协议签名一致;`tests/unit/test_ports.py` 的 Protocol 断言更新。
**测试**(先失败后通过): `tests/unit/test_retry.py::test_request_tier_reaches_transport`(断言 fake 收到的 `reasoning_effort``ChatRequest` 一致)、`::test_embedding_transport_signature_unchanged`(回归: 未误改另两个协议)。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_backpressure.py tests/unit/test_ports.py -v` → PASS
- [ ] 提交: `feat: carry the per-call tier down to the transport that must send it`
---
## Task 6 — 缓存 key
**文件**: `src/polygateway/client.py`(改)、`src/polygateway/middleware/cache.py`(改)、`tests/unit/test_cache.py`(改)、`tests/integration/test_redis_cache.py`(改,该文件亦断言 key 形状)
**行为**:
1. `_fingerprint_mark`: 源级 `reasoning_effort` **仅在非 `None` 时**追加,规则与 `enable_thinking` 完全一致——全源不表态时指纹字面量逐字不变,存量缓存不冷启动。
2. `build_cache_key` 增关键字参数 `reasoning_effort: Effort | None = None`,**仅非 `None` 时**写入 `key_obj["reasoning_effort"]`
3. `CacheMW.__call__``request.reasoning_effort`
**为什么两处都要**(写进注释): `model_fingerprint` 是装配期算的**集合级**指纹,覆盖不到逐次调用变化的请求级档位;不进 key 则同 messages 跑 low 与 max 互相命中,是 issue #4「5 个 seed 全命中同一响应」的逐字翻版。ARCH §7.5 记载的「集合级指纹仍可能返回另一源响应」这一既有取舍原样延续,本任务不扩大。
**验收**: 同 messages 不同请求级档位 → key 不同;两者皆不表态 → key 与升级前逐字相同(回归);源级档位变化 → fingerprint 变化。
**测试**(先失败后通过): `::test_request_tier_changes_key``::test_absent_tier_keeps_legacy_key`(断言具体 key 字符串不变)、`::test_source_tier_enters_fingerprint`
**验证**: `conda run -n PolyGateway pytest tests/unit -k "cache or fingerprint" -v` → PASS
- [ ] 提交: `fix: keep a low-tier answer out of the cache slot a max-tier one filled`
---
## Task 7 — 遥测新增 `reasoning_effort`
**文件**: `src/polygateway/telemetry/schema.py``src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``src/polygateway/middleware/telemetry.py`(均改)、`tests/unit/test_telemetry.py`(改,含列数断言)、`tests/unit/test_ports.py`(改,Protocol 签名断言)、`tests/integration/test_postgres_telemetry.py`(改——该文件有 `_EXPECTED_COLUMNS` 完整**列序**断言与 pre-tenant 历史 DDL 的列子集推导,共 5 处,漏改则 PG 集成测试必红)、`src/polygateway/middleware/retry.py`(改,`emit_attempt` 调用点传新参)
**行为**:
1. `schema.py`: `COLUMNS` 末尾加 `"reasoning_effort"`(INSERT 字段 25 → 26,物理列 26 → 27);两端 DDL 追加 `reasoning_effort TEXT`(位置与 ALTER 追加一致);补列声明同步。**列数断言按物理列写**——两套口径混用是本模块最易错处(见其 docstring)。
2. `ports.py`: `record_llm_call``reasoning_effort: str | None`(**不设默认值**,与既有约定一致: 库外无第三方实现者,少写一列会被 emitter 降级吞成 warning);docstring 的「25 字段冻结」改 26。
3. 两个 recorder 落库新列。
4. `middleware/telemetry.py`: `_record` 加参并传给 recorder(**唯一** `record_llm_call` 调用点,不复制参数列表);`emit_attempt``applied_effort` 关键字参数,由其三个调用方传值——`retry.py:411` 传实际档,`embedding.py:407``ocr.py:451``None`(无推理语义)。三个 emit 入口取值口径分列:
| 入口 | 取值 | 理由 |
|---|---|---|
| `emit_attempt` | 成功时 `response.applied_effort`(T8 送上来的实际档);**失败时**回落到 `effective_effort(...)` 的请求档 | **不是**请求档: `nearest` 映射后二者不同(请求 `medium` → 实际 `LOW`),记请求档会让 T10 的压测把行挂在从未发出的档下。失败尝试没有 response,实际档不可知,记请求档并接受这一含义差别——总好过 issue #19 抱怨的「失败行无归因」 |
| `emit_cache_hit` | `request.reasoning_effort` | 缓存命中没有选中源,源级档位无从谈起 |
| `emit_terminal_failure` | `request.reasoning_effort` | 同上(可能根本没选出源) |
`sampling` 列的现有做法同构(`emit_attempt` 合并源级,另两处只取请求级)。
5. 值为 `Effort` 时取 `.value` 落库,`None``NULL`——与 `thinking_observation` 同一先例(`StrEnum``str` 子类,asyncpg 对子类编码不保证接受,遥测写失败只降级 warning,PG 那一路会静默少列)。
**验收**: 两端建表列数断言更新且通过;三个入口各自落值正确;不表态时为 `NULL`;`telemetry_schema_sql` 打印的 SQL 与库实际执行的 DDL 同源。
**测试**(先失败后通过): 既有遥测列数断言用例更新;`::test_effort_column_records_effective_tier``::test_cache_hit_records_request_tier_only``::test_absent_tier_is_null`
**验证**: `conda run -n PolyGateway pytest tests/unit tests/integration -k telemetry -v` → PASS
- [ ] 提交: `feat: record which tier a call actually ran at`
---
## Task 8 — transport 接线与对账
**文件**: `src/polygateway/transports/openai_compat.py`(改)、`src/polygateway/thinking.py`(改)、`tests/unit/test_openai_compat.py`(改)
**行为**:
1. `_build_payload`: 用 `effective_effort(...)` 求生效档位后调 `resolve_thinking(..., fallback=source.effort_fallback)`。注入结果仍**先于** `source.extra_body``overlay`(顺序即优先级,issue #4 决策 A,两行不可调换)。
2. `_warn_on_thinking_mismatch` 的节流键由 `(source.name, source.model, source.enable_thinking)` 改为 `(source.name, source.model, effective_effort)`——同一模型的 low 与 max 是两个独立的矛盾,共用一个键会让第二个永久静音。
3. `reconcile_thinking` 签名的 `enable_thinking: bool | None` 改为 `effort: Effort | None`,判据: `effort is NONE` 对应原「要求关闭」分支,`effort` 为其余档对应原「要求开启」分支,`None` 仍返回 `None`。**不新增**「档位高低 vs `reasoning_tokens` 多少」的对账(设计 §4.3: 无可判定的函数关系,拿它报警必然是噪声)。
4. `ThinkingUnsupportedError` 的捕获与翻译路径不变(→ `RequestRejectedError`,不重试不换源不计熔断)。
5. **把实际档送出 transport**(否则遥测记不到 `nearest` 映射后的真实档):
- `TransportResult` 末尾追加 `applied_effort: Effort | None = None`——带默认值,非 OpenAI 兼容的 transport(OCR/embedding)可不填,与 `thinking_observation` 同一先例;
- `LLMResponse` 末尾追加 `applied_effort: Effort | None = None`——**字段只增不删不改名**,符合 ARCH §5.1 迁移兼容约束;对下游也有价值(它终于能知道这次实际跑在哪档);
- `RetryMW``retry.py:375``TransportResult → LLMResponse` 转换处带上该字段。
**验收**: 档位不支持时抛 `RequestRejectedError` 且不触发重试与熔断计数;同源同模型不同档各喊一次告警;`reconcile` 三类文案与既有逐字一致(除方向描述由 bool 改档位);`nearest` 映射后 `LLMResponse.applied_effort` 是**映射后**的档。
**测试**(先失败后通过): `::test_unsupported_tier_is_request_rejected``::test_no_retry_on_tier_error``::test_throttle_key_separates_tiers``::test_reconcile_none_vs_observed``::test_response_carries_mapped_tier`(请求 `medium`、能力 `(LOW,HIGH,MAX)` → 断言 `response.applied_effort is Effort.LOW`)。
**验证**: `conda run -n PolyGateway pytest tests/unit -k "transport or openai_compat" -v` → PASS
- [ ] 提交: `feat: wire the tier through the transport and keep each tier's warning distinct`
---
## Task 9 — 全套件、文档与 wiki
**文件**: `CHANGELOG.md``.env.example`(复核)、Gitea Wiki(按 `research-wiki/docs-convention.md` §2)、`src/polygateway/__init__.py`(版本号)、`pyproject.toml`(版本号)
**行为**:
1. `make lint` + `make test` 全绿;`make format`
2. CHANGELOG 加「未发布」段: 破坏性变更(`ThinkingCapability` 构造签名)、新增(八档 `Effort`、两个 env 键、遥测新列、四个 provider 段)、行为变更(`openai` 段两档由未知改为 OpenAI 标准形态)。
3. 按 docs-convention §2 同步 wiki(公共行为变更必须同步,版本 bump 不得裸发)。**CHANGELOG 必须覆盖三条行为变更**,漏第三条是独立验证点名的风险: ① `ThinkingCapability` 构造签名(破坏性);② minimax/openai/anthropic/google 开档不再注 `medium`;③ `openai` 兜底段由「形态未知即报错」放宽为标准形态——把别家模型挂在该段下并配 `ENABLE_THINKING=true` 的下游,旧版装配期报错,新版静默不注入任何字节(对这四段涉及的模型无害,它们默认即推理;但语义变了,须明写)。
4. 版本号 **`1.3.3`**(2026-09-05 人类指令;不因破坏性变更走 minor),`pyproject.toml``src/polygateway/__init__.py` 两处一致。**本任务只 bump 不发布**——发布走 CLAUDE.md §4.4.1 全清单。
**验收**: `make ci` 通过;CHANGELOG 与 wiki 均含破坏性变更条目。
**验证**: `conda run -n PolyGateway make ci` → PASS
- [ ] 提交: `docs: cut 1.3.3 notes for the tier work`
---
## Task 10 — e2e 实测校正初始能力表(标 `slow`)
**文件**: `tests/e2e/test_thinking_live.py`(改)、`src/polygateway/thinking.py`(改——`DEFAULT_CAPABILITIES` 与 evidence 就在此处,实测结论要写回它,否则本任务只跑不改,设计 §8/§13 第 5 条落不了地)
**行为**: 对 §Task 1 表中每个已登记模型,经 new-api 实测其 `supported_efforts`,方法论沿用 issue #20: 固定短提示词,逐档 N≥5,判据取 `usage.completion_tokens_details.reasoning_tokens`;对声明不可关的模型额外验证「请求 `none` 是否真被拒或真未关」。测试标 `slow`(成败取决于外部服务当下状态,默认不进日常套件)。实测结论逐条替换 `evidence` 中的「文档推定」。
**为什么必须单列一个任务**: 人类 2026-09-04 定「能力表数据统一自己经 new-api 实测」;Task 1 落的是文档推定值,不实测则整张表都是假设。
**验收**: 每个已登记模型有一条实测记录;与文档推定不符者更新 `supported_efforts` 并在 evidence 记明分歧(尤其 `kimi-k3` 的保守登记、`gemini-3.1-pro` 的默认档两源打架)。
**验证**: `conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -m slow -v` → PASS(约 20-40 分钟,取决于网关)
- [ ] 提交: `test: replace the guessed tier table with what the gateway actually does`
---
## 执行顺序与依赖
```
T1(词汇+能力表) ──┬─→ T3(五关) ─────────────→ T8(transport)
T2(wire) ─────────┘ ↑
T4(源级) ─→ T5(请求级字段) ─→ T5b(端口签名) ──┤
│ │
└─→ T6(缓存 key) ↓
T7(遥测) ─→ T9(文档) ─→ T10(实测,回写能力表)
```
T1/T2 可并行;T3 依赖两者;T5 依赖 T4(语法糖等价关系);**T5b 依赖 T5**(要有 `ChatRequest.reasoning_effort` 才有得传);T6 依赖 T5;T8 依赖 T3 + T5b(没有 T5b 就拿不到请求级档位);**T7 依赖 T8**(自审纠正: 遥测要记的实际档由 T8 在 transport 内算出并经 `TransportResult`/`LLMResponse` 送上来,先做 T7 只能记到请求档);T9 在功能任务全绿后;T10 最后,且它会**改回 `thinking.py`**——与 T1 同一文件,故必须排在最后而非与其并行。
执行方式: 10 个任务耦合度中等(共享 `Effort`/`ThinkingCapability`/`ThinkingWire` 三个类型),**直接按计划实现**,不派 `subagent-driven-development`——跨任务共享类型多,独立上下文的 subagent 容易在签名上分叉。
@@ -0,0 +1,37 @@
---
type: plan
node_id: plan:issue10-error-body-retention-plan
title: "实现计划: HTTP 错误响应体留存(Issue #10)"
date: 2026-08-16
---
# 实现计划: HTTP 错误响应体留存(Issue #10)
**全文**: `plans/2026-08-16-issue10-error-body-retention.md`|**实现**: [[design:issue10-error-body-retention]]|**分支**: `feat/issue-10-error-body-retention`
## 任务分解
| # | 任务 | 产出 |
|---|---|---|
| 1 | 内核字段 | `PolyGatewayError.body_text`(默认空串)+ 与 `raw_text` 的界限 docstring |
| 2 | 共享摘要单元 | 新建 `transports/_http_errors.py`:`summarize_body` / `compose_message` / `response_body` |
| 3 | openai_compat 收口 | `_status_to_error` 表驱动;429 判类型仍读原文 |
| 4 | monkey_ocr 收口 | `_classify_status` 同款,含 `ResponseNotRead` 降级 |
| 5 | **端到端验收** | 400 调用后 SQLite `error` 列含摘要——本计划的硬判据 |
| 6 | 文档与版本 | 1.2.0、README pin `>=1.2,<2`、CHANGELOG、ARCHITECTURE §6.2 |
| 7 | 合并前门 | lint + 全套件 + 全新上下文 verifier |
依赖:1‖2 → 3‖4 → 5 → 6 → 7。
## 计划期钉死的两条实现红线
1. **429 判 `insufficient_quota` 必须解析未截断原文**,不得改用摘要——摘要会破坏 JSON,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`,把诊断改进变成治理 bug。Task 3.4 有专门回归用例。
2. **message 主体逐字保持 1.1.2 原样**,只在末尾追加摘要后缀;状态码→分类映射逐条不变,验收要求既有分类断言零改写。
## 保真校验
不涉及 `reference/` 迁移。错误分类映射不变,保真体现为"既有分类断言全部保留"。
## 执行期观察
Task 计划提交时 pre-commit 钩子的全套件跑出现一次 `tests/e2e/test_compat_projects.py::TestGovDocOnboarding::test_call_site_shape_runs_governed` 失败,单独跑与 e2e 全目录跑(7 passed / 23.30s,每例 2-3.5s)均通过,重跑全套件亦通过 → 判为真实网关抖动,非回归。该用例正是 [[design:issue8-stall-budget]] 当年记录的三个漂移用例之一,e2e 打真实网关的固有 flaky 面仍在。
@@ -0,0 +1,17 @@
---
type: plan
node_id: plan:issue11-caller-dimensions
title: "调用方自定义维度实现计划(issue #11)"
date: 2026-08-17
---
# 调用方自定义维度实现计划(issue #11)
正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`
- **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。
- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit``ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。
- **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。
- **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。
- **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。
@@ -0,0 +1,18 @@
---
type: plan
node_id: plan:plan-issue12-telemetry-retention
title: "实现计划: issue12-telemetry-retention"
date: 2026-08-19
---
# 实现计划: issue12-telemetry-retention
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标写入)。
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter``text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient``__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py``PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。
@@ -0,0 +1,16 @@
---
type: plan
node_id: plan:plan-issue13-schema-mode
title: "实现计划: issue13-schema-mode"
date: 2026-08-19
---
# 实现计划: issue13-schema-mode
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。
- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS`**INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint``--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。
@@ -0,0 +1,312 @@
# 实现计划: 熔断拒绝补齐等待档(issue #14)
- **设计**: `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`(人类已确认 + Codex 已审)
- **分支**: `feat/issue-14-circuit-open-policy`
- **版本**: 1.3.0(新增配置键 + `retry_after_s` 语义变更)
## 目标
让"源不健康"不再等同于"这次调用当场判死"——补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 这一格准入策略,并把 `retry_after_s` 的语义在两个后端的五个出口上定死。
## 方案概述
三件事环环相扣: ①把 `retry_after_s` 定义为"距离**确定**可再试的时刻还有多久",HALF_OPEN 与准入允许一律 `0.0`(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 `circuit_open` 策略键,`wait` 档下不抛 `CircuitOpenError` 而按 `retry_after` 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。
涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。
## 保真校验适用性
**适用**。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 `reference/Video-Tree-TRM5/adapters/breaker.py``reference/CHSAnalyzer/app/coordination/provider_gate.py`),准入循环蓝本为 `reference/CHSAnalyzer/app/providers/governance.py:107-285`。T1 与 T2/T3 各带保真校验检查点。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/admission.py` | **新建** | `SourceAdmission`(准入与无源可跑的处置,三条循环共用)+ 模块级 `settle_and_release` |
| `src/polygateway/middleware/retry.py` | 修改 | 删除本地 `_pick_runnable`/`_on_no_runnable`/`_settle_and_release`,改用 `SourceAdmission`;主循环与 `_attempt` 不动 |
| `src/polygateway/embedding.py` | 修改 | 同上 |
| `src/polygateway/ocr.py` | 修改 | 同上(注意 `_settle_and_release` 原签名只有 `permit`) |
| `src/polygateway/backends/memory/breaker.py` | 修改 | 抽 `_remaining(g)`,三处出口共用;HALF_OPEN 与授予探针恒 `0.0` |
| `src/polygateway/backends/redis/breaker.py` | 修改 | 五个 Lua 出口同步(`TRY_ENTER` 两处、`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE` 各一处、`RETRY_AFTER` 一处) |
| `src/polygateway/config.py` | 修改 | `_CIRCUIT_OPEN` 常量、`GatewaySettings.circuit_open` 字段、`_validate_backends` 元组、`from_env` 装载 |
| `src/polygateway/client.py` | 修改 | 构造签名 + 透传 |
| `src/polygateway/errors.py` | 修改 | `GatewayUnavailableError` docstring 职责边界 |
| `tests/contracts/test_breaker_contract.py` | 修改 | 按五个出口逐个钉 `retry_after_s` |
| `tests/integration/test_redis_governance_time.py` | 修改 | Redis 真实等待变体补 HALF_OPEN 出口 |
| `tests/unit/test_backpressure.py` | 修改 | `circuit_open` 行为矩阵、备忘污染回归、`_nap` 上界 |
| `tests/unit/test_config.py` | 修改 | 新键的合法域、缺省、两条装配路一致 |
## 关键接口(跨任务消费,此处定死)
`SourceAdmission` 构造与两个方法:
```python
class SourceAdmission:
def __init__(self, *, scope: str, sources: list[SourceConfig],
selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
quota_full: str, circuit_open: str,
pacer: AdaptivePacer | None = None,
health_view: Callable[[str], float] | None = None,
now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...
async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...
async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
clock: StallClock) -> None: ...
async def stalled(self, clock: StallClock) -> bool: ...
```
`quota`/`breaker`/`pacer`/`selector`/`sources` 均为**调用方传入的同一实例**(不在 admission 内新建),因为三处 `_attempt` 仍需引用它们;`memo` 则由 admission 独占。`health_view` 对应 chat 的 `self._health_view`(由 `isinstance(selector, OutcomeAwareSelector)` 在 RetryMW 构造期判定一次),embedding/ocr 传 `None`
模块级结算函数(三处 `_attempt` 的 finally 与 admission 共用):
```python
async def settle_and_release(permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
```
睡眠时长(T5 实现,写死在 `SourceAdmission._nap`):
```python
def _nap(self, hint: float, clock: StallClock) -> float:
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
wait = hint + jitter if hint > 0 else jitter
return max(jitter, min(wait, budget))
```
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05``jitter∈[0.025,0.05]``budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。
## 任务清单
### T0 — 分支与基线
- [ ] 建分支 `feat/issue-14-circuit-open-policy`(从 main)
- [ ] 记录基线: `conda run -n PolyGateway python -m pytest tests/ -q``make check` + `lint-imports` 全绿,记下**本机本环境**的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——`addopts = "-m 'not slow'"` 与 Redis 可达性都会改变它,不作硬验收
**验证**: `conda run -n PolyGateway python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确
---
### T1 — 纯重构: 准入逻辑三处收敛(回滚点)
**动**: 新建 `src/polygateway/middleware/admission.py`;改 `middleware/retry.py``embedding.py``ocr.py`
**要实现的行为**: 把 `_pick_runnable`/`_on_no_runnable`/`_stalled`/`_settle_and_release` 从三处搬进 `SourceAdmission` 与模块级 `settle_and_release`,三条循环改为持有 `SourceAdmission` 实例并调用其方法。**本任务不引入 `circuit_open` 参数**(构造签名先只收 `quota_full`,T4 再加),控制流一字不改。
三条循环的差异只用注入表达,不留 `if` 分支:
| 差异 | 处理 | 等价性依据 |
|---|---|---|
| 调用内降权(仅 chat) | `attempt_fails``pick()` 入参,内部无条件调 `_demote_call_failures` | 传空 dict 时 `demoted` 为空 → `return ordered` 原对象返回,恒等(`retry.py:148-150`) |
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit()``enter()` 两个调用点,无副作用 |
| `_settle_and_release` 签名 | OCR 原为 `(permit)`、体内恒 `settle(0)`;改为调 `settle_and_release(permit, 0)` | 逐字等价 |
| warning 文案**三处都不同** | 归一为 "permit 结算/释放失败(不掩盖主异常)" | chat `retry.py:536` 已是该文案;embedding `embedding.py:411` 为 "embedding permit …"、OCR `ocr.py:448` 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务**唯一**的可见行为变化,须在提交信息里点名 |
| `_stalled` 形态 | chat 已抽成方法,embedding/ocr 为内联表达式 | 两者语义逐字相同(已 diff 核实),统一用 `SourceAdmission.stalled()` |
**搬走 vs 共享(自审修正,这一条决定 T1 能否成立)**: 三处 `_attempt` 仍在引用 `self._breaker`(记账写回)、`self._quota`(mark_progress)、`self._pacer`(leave)、OCR 还有 `self._selector`(健康喂数,`ocr.py:426`)。因此这些字段**不搬走,而是共享同一实例**——循环保留自己的引用,构造 `SourceAdmission` 时把同一对象传进去(`AdaptivePacer` 有在途计数状态,必须是同一实例而非新建,否则 `admit`/`enter``leave` 分裂到两个计数器上)。真正搬走的只有 `_pick_runnable`/`_on_no_runnable`/`_stalled` 三个方法与 `self._memo`(仅被 `pick` 消费)。
**`_attempt` 的唯一改动**: `self._settle_and_release(permit, actual)` → 模块级 `settle_and_release(permit, actual)`,OCR 侧由 `(permit)` 变为 `(permit, 0)`。除此之外 `_attempt` 一行不动。原稿"三处 `_attempt` 本体不在边界内"的说法与"搬走 `_settle_and_release`"自相矛盾,此处更正。
**不在边界内、须原样保留**: chat 主循环顶部那次额外的 `_stalled` 预判(`retry.py:286`)、OCR 的 `_gate_on_terminal`(`ocr.py:412`)与健康喂数。
**保真校验检查点**: 对照 `reference/CHSAnalyzer/app/providers/governance.py:107-285`,确认搬运后 `_pick_runnable` 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、`gate_rejections` 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、`_on_no_runnable` 的三段判定顺序逐段未变。
**测试要求(本任务特殊)**: **不新增行为用例**。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次**机械差异审查**: 把搬迁前后的 `pick`/`on_no_runnable` 逐语句对照,确认候选跳过顺序、`gate_rejections` 计入规则、`reasons``[]=``setdefault` 用法(两者语义不同,不可互换)一字未变。
**已知会碰到的既有测试**: `tests/unit/test_health_selector.py:146` 断言 `client._terminal._pacer._ceiling`,`tests/unit/test_client.py:380` 断言 `._terminal._emitter._text_cap`——这两个字段必须留在 `RetryMW` 上(与上面"共享而非搬走"一致),否则这些用例会红。
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/ -q # 期望: 全 PASS,计数与 T0 同环境基线一致
conda run -n PolyGateway make check # 只读: ruff format --check + ruff check
conda run -n PolyGateway lint-imports # 依赖铁律
```
**不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check` + `lint-imports`。用例计数只作**同环境**参照,不作硬验收: `pytest` 默认 `-m 'not slow'`(`pyproject.toml:51`),且无 `REDIS_URL` 时 Redis 用例 skip,计数随环境浮动。
import-linter 层级(`pyproject.toml:76`)允许 `middleware/admission.py` 依赖 `ports`/`types`/`errors`/`sources`(更内层),但不得 import 任何 `backends/``transports/``telemetry/`。搬迁后须清理三个原文件中失去引用的 import(`CircuitOpenError``QuotaGate``BreakerGate``SourceCooldownMemo` 等),否则 ruff 报未使用导入。
- [ ] 提交: `refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission`
---
### T2 — `retry_after_s` 语义统一(两个后端一次到位)
**动**: `src/polygateway/backends/memory/breaker.py``src/polygateway/backends/redis/breaker.py``tests/contracts/test_breaker_contract.py``tests/integration/test_redis_governance_time.py`
**为什么两个后端必须同一个提交(Codex 审查修正)**: 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 `.claude/settings.json` 注册的 `pre-commit-guard.sh` 在检测到 `git commit` 时会跑 `pytest tests/ --tb=line -q`(`pre-commit-guard.sh:61`),红态直接卡住提交。且两者本就是**同一个契约的两个实现**,分开提交没有独立意义。
**要实现的行为**: `retry_after_s` = "距离**确定**可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 `0.0`;准入被允许时同样恒 `0.0``0 = 可立即重试` 是库既有约定(`errors.py` 与现有契约用例"健康 → 0、冷却到期 → 0")。
memory 侧: 抽私有纯方法 `_remaining(g: _SourceGate) -> float`(OPEN 返回 `max(0.0, g.open_until - now)`,其余状态含 HALF_OPEN 返回 `0.0`),`try_enter` 的 HALF_OPEN 拒绝分支(`memory:148`)与 `retry_after_s()`(`memory:267`)改用它。`_snapshot`(`memory:169`)与授予探针(`memory:114`)已符合新契约,保持不变。
redis 侧共**六个返回格**,逐处点名(改前先确认行号仍对得上):
| 脚本 | 位置 | 现状 | 改为 |
|---|---|---|---|
| `TRY_ENTER` HALF_OPEN 拒绝 | `redis:44` | `probe_until - now` | `0` |
| `TRY_ENTER` 授予探针 | `redis:53` | `tonumber(ARGV[2])`(= probe TTL) | `0` |
| `RECORD_SUCCESS` fencing 未命中 | `redis:124` | half_open 取 `probe_until` | half_open 记 `0`(只 OPEN 取 `open_until - now`) |
| `RECORD_FAILURE` fencing 未命中 | `redis:155` | 同上 | 同上 |
| `RELEASE_PROBE` fencing 未命中 | `redis:255` | 同上 | 同上 |
| `RETRY_AFTER` | `redis:275` | half_open 取 `probe_until` | half_open 记 `0` |
后四行修的是**既有的双后端语义分叉**(memory `_snapshot` 对非 OPEN 一律 `0.0`),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。
**保真校验检查点**: 状态机转换、双通道开路判据、`_cooldown_eff` 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 `redis.call('TIME')` 服务器时钟口径**一律不动**——本任务只改"对外报几"这一件事,即 return 元组里 `retry_after_ms` 那一格。改完逐脚本与 memory 实现对照走一遍状态机。
**测试要求**(先失败后通过,`tests/contracts/` 双后端参数化,一次覆盖 memory + redis):
- HALF_OPEN 被拒: `decision.retry_after_s == 0.0``decision.state is GateState.HALF_OPEN`
- 授予探针的决定: `retry_after_s == 0.0`
- `record_*` 在 fencing 未命中且门处于 HALF_OPEN: `GateUpdate.retry_after_s == 0.0`(须同时断言 `applied is False``state is HALF_OPEN`,否则用例可能在别的分支上误绿)
- `gate.retry_after_s(("s1",))` 探针在途时返回 `0.0`
- 现有 `test_retry_after_semantics` / `test_retry_after_takes_min_across_sources` 保持绿(OPEN 语义未变)
**Redis 时间语义变体**: 契约层用 `clock.advance()` 的用例在 redis 参数下会 skip(`conftest.py:39``SkipClock` 哨兵),故须在 `tests/integration/test_redis_governance_time.py` 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 `test_meta_variants_cover_all_time_cases`(`:56`)会**机械拦截**漏配,漏了就红。
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q # 双后端全 PASS
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q
```
第二条**必须带 `-m slow`**: `pyproject.toml:51``addopts = "-m 'not slow'"` 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。
- [ ] 提交: `fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)`
---
### T3 — (已并入 T2)
原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。
---
### T4 — 新配置键 `{SCOPE}__CIRCUIT_OPEN`
**动**: `src/polygateway/config.py``src/polygateway/client.py``src/polygateway/middleware/admission.py``embedding.py``ocr.py``tests/unit/test_config.py`
**要实现的行为**: 与 `quota_full` 逐项同构,不发明新形状。
| 位置 | 改动 |
|---|---|
| `config.py` 常量区 | `_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})`,紧邻 `_QUOTA_FULL` |
| `GatewaySettings` | 新增字段 `circuit_open: str`,**无默认值**(与该类全部既有字段一致),位置紧随 `quota_full` |
| `_validate_backends` | 校验元组加一行 `("circuit_open", _CIRCUIT_OPEN)` |
| `from_env` | `circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")` |
| `client.py` | `GatewayClient.__init__``circuit_open: str = "fail_fast"`;`from_settings` 透传 `settings.circuit_open` |
| `admission.py` | 构造收 `circuit_open`,同 `quota_full` 做构造期域校验并抛 `ValueError` |
| `embedding.py` / `ocr.py` | 两个客户端的构造签名与"从 GatewayClient 派生"路径(`embedding.py:561``ocr.py:574` 邻域)各透传一处 |
**缺省取 `fail_fast`**(人类 2026-08-19 决策): 保证控制流对存量下游不变。
**测试要求**(先失败后通过):
- 缺省档: 不设该键时 `settings.circuit_open == "fail_fast"`
- 合法域: 设为 `"nope"``from_env` 与直接构造**两条路**都抛 `ValueError` 且消息点出键名/字段名
- 两条装配路一致: `from_env` 与直接构造同一取值产出同一行为
- `dataclasses.replace(settings, circuit_open="wait")` 仍通过全部装配守卫
- 透传链: 从 `GatewaySettings` 一路到三条循环的 `SourceAdmission` 实例上取值正确
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
```
- [ ] 提交: `feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)`
---
### T5 — `on_no_runnable` 按原因分派 + `_nap`
**动**: `src/polygateway/middleware/admission.py``tests/unit/test_backpressure.py`
**要实现的行为**: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:
1. `gate_rejections == len(sources)`(全部因熔断类原因被拒)时,`fail_fast``CircuitOpenError`(现行为),`wait``hint = await breaker.retry_after_s(names)` 后**不抛**;
2. 否则(至少一源是被配额/AIMD 挡的)走 `quota_full` 分支,`hint = 0.0`;
3. 两条路汇合后统一判 `stalled()`,再 `await sleep(self._nap(hint, clock))`
**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`
**可观测性**: `wait` 档每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。
**取消穿透**: `_nap` 只做算术,睡眠是裸 `await self._sleep(...)`,不得包 `try/except`
**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons``circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
- `circuit_open=wait` + 源持续 `force_open`**`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放
- `circuit_open=fail_fast`(缺省)下,全部现有用例逐字绿
- **备忘污染回归**(issue #14 §1.3): 探针成功后 `memo.active(源名)` 为 False,该源立即重新可选——此用例由 `/tmp/.../probe_repro.py` 的复现脚本转化而来,在 T2 之前必然 red
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
conda run -n PolyGateway python -m pytest tests/ -q # 全套件
```
- [ ] 提交: `feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死`
---
### T6 — `errors.py` 职责边界补写
**动**: `src/polygateway/errors.py`
**要实现的行为**: 改写 `GatewayUnavailableError` 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同(ARCH §7.2 单层重试原则)。
`retry_after_s` 那句保留并补一句: 它是"距离确定可再试的时刻",`0` 表示无确定等待(可立即重试)。
**测试要求**: 纯 docstring,无行为变更。验收为 `tests/unit/test_errors.py` 保持绿。
**验证**: `conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q`
- [ ] 提交: `docs: 收回 GatewayUnavailableError 的重试职责边界`
---
### T7 — 文档同步
**动**: `research-wiki/ARCHITECTURE.md``README.md``CHANGELOG.md`、Gitea wiki。
| 目标 | 内容 |
|---|---|
| ARCH §7.4 | 增补本次决策: 三条缺陷的成因、`retry_after_s` 的契约定义(五个出口)、`circuit_open` 策略键与缺省理由 |
| ARCH §9 配置面 | 登记 `{SCOPE}__CIRCUIT_OPEN` |
| README | 配置表新增该键;**明写"单源 scope 建议配 `wait`"**——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改 |
| CHANGELOG | 记 1.3.0,`retry_after_s` 语义变更给"请先读这一条"待遇(缺省档下 `CircuitOpenError.retry_after_s` 在全源 HALF_OPEN 时由探针租约剩余变为 0) |
| Gitea wiki | 按 `research-wiki/docs-convention.md` §2 清单同步 |
**验证**: 人工逐项核对上表;`grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md` 各有命中。
- [ ] 提交: `docs: 记录熔断等待档与 retry_after_s 契约`
---
### T8 — 合并前独立验证
- [ ] 派**全新上下文** verifier subagent(`verification-before-completion`),逐条核对: 设计每一节是否有对应实现、五个 `retry_after_s` 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
- [ ] `conda run -n PolyGateway make check` + `conda run -n PolyGateway lint-imports` 全绿(**不用 `make lint`**,它带 `--fix` 会改文件)
- [ ] `conda run -n PolyGateway make test` 全套件绿 + 覆盖率 ≥ 80%
- [ ] Redis integration 套件在真实 Redis 上绿,含 `-m slow` 的时间语义变体(默认 addopts 会排除它)
- [ ] `requesting-code-review` 走一次整分支审查
- [ ] `finishing-a-development-branch`: `--no-ff` 合并 main,合并后在 main 上重跑 lint 与全套件
**注**: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,**不在本计划范围**,需人类确认后单独执行。
## 自审记录
- 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
- 无 TBD/TODO/"适当的错误处理"类占位
- 跨任务消费的 `SourceAdmission` 签名、`settle_and_release``_nap` 公式已在"关键接口"写出实际代码
- 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦
@@ -0,0 +1,18 @@
---
type: plan
node_id: plan:plan-issue15-telemetry-pool-lifecycle
title: "实现计划: 遥测连接池的资源语义与生命周期(issue #15)"
date: 2026-08-24
---
# 实现计划: 遥测连接池的资源语义与生命周期(issue #15)
正文: `2026-08-24-issue15-telemetry-pool-lifecycle.md`(380 行)。实现 [[design:issue15-telemetry-pool-lifecycle]]。状态: **T0–T7 全部完成 + T8 处置独立验证发现的 5 个问题(2026-08-24)**,提交表见正文末尾。
- **八个任务**: T0 分支与基线(把已完成的 Python 3.12 迁移落盘)→ T1 D 组所有权纪律(独立回滚点)→ T2 C 组 tracker 与状态快照 → T3 A 组池语义与两个新配置键 → T4 有界关闭 → T5 B 组失败三分与冷却降级(核心)→ T6 真实 PG 集成验证 → T7 文档与发布说明。
- **顺序的关键理由**: tracker(T2)排在池语义(T3)与失败判据(T5)**之前**——后两步的每个降级点都要向 tracker 报告,反过来做要把日志代码返工一遍。代价是 T2 结束时 `_failed` 与 tracker 状态**临时并存**(为了让 T2 能独立全绿提交),T5 必须收掉,两份状态只允许存活一个任务的跨度。
- **执行前必读的两条事实**: ① Python 3.12 迁移的改动**还在 main 的工作区未提交**(T0 第一件事就是落到分支);② **建池路径今天零测试覆盖**——全 `tests/``create_pool`/`_open_pool` 的引用数为 0,现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入、走 `_external_pool=True` 分支从不建池。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要建这一路的**第一个**用例。
- **提交门是任务边界的实际约束**: `.claude/scripts/hooks/pre-commit-guard.sh` 对每次 `git commit` 阻塞式跑 ruff + radon(圈复杂度 ≥C 即拦)+ 全套件。由此两条硬约束: 不得留红态跨提交(不能把一个行为拆成"改实现"和"改测试"两次);T5 同时改三个降级点,`record_llm_call` 逼近 C 时必须抽私有方法——这不算计划外重构,是提交门的硬要求。
- **两条既有承诺挂了检查点,不得被本次改动破坏**: [[design:issue13-schema-mode]] 的"manual 档缺列时裁剪 INSERT 继续写、逐行暴露"(故 `42703` 是失败分类的唯一具名例外)、[[design:issue9-telemetry-ddl-probe]] 的"表存在就绝不发 DDL"(`to_regclass` 探测那段控制流一行不动)。
- **保真校验不适用**: 遥测后端无 `reference/` 蓝本(ARCH §7.8 明记"参考仓无先例: 三项目遥测全 SQLite")。
+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/` 移植蓝本;且三项目当前不在工作区)。
@@ -0,0 +1,30 @@
---
type: review
node_id: review:issue14-branch-review
title: "整分支审查: issue #14 熔断等待档"
date: 2026-08-20
---
# 整分支审查: issue #14 熔断等待档
- **范围**: `feat/issue-14-circuit-open-policy`,296c765..5a025b6(8 提交,src 6 文件 + tests 5 文件)
- **审查方**: Codex 全新上下文只读审查(两轮: 独立验收 + 整分支审查)
- **结论**: **needs_changes → 修正后 approved**;Critical 0 项
## 发现与处置
| 级别 | 发现 | 核实 | 处置 |
|---|---|---|---|
| Important | `circuit_open=wait` + 持续 `force_open` 实际抛 `retry_exhausted` 而非文档声称的 `stalled` | **成立**。冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`;审查方以单源 + 连续 `SourceDeadError("401")` 复现,本地补测试复现一致 | **改文档不改代码**——该行为符合 issue #8 确立的"划分依据是谁消耗重试预算"。修正 CHANGELOG / README / 设计 §4 行为矩阵 / 计划 T5,并补 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉死 |
| Minor | 计划要求进入/退出等待各一条日志,实现只有进入那条 | 成立 | **保持一条**,修计划措辞: 每轮等待各自留痕已可还原时间线,醒来后若仍被拒会立刻打下一条,补"醒来"只会让日志量翻倍 |
| — | 上一轮独立验收挑出计划 `_nap` 伪码下界与实现不一致(`poll_interval_s` vs `jitter`) | 成立 | 实现是对的(用 `poll_interval_s` 会把既有 quota 轮询的 `rng→0` 半边从 `0.5p` 抬到 `1.0p`),已回填计划 |
审查方两轮均确认: T1 收敛行为等价、六个 `retry_after_s` 出口齐备、备忘污染闭合、取消穿透与 permit/pacer 配对无泄漏、缺省档控制流不变。
## 验证证据(本会话工具输出)
- 全套件 `pytest tests/ -q`: **980 passed, 25 skipped, 36 deselected**(基线 967 passed;+13 为新增用例)
- 覆盖率 `make test`: 总 **94%**(`admission.py` 93%、`config.py` 99%、`memory/breaker.py` 96%)
- Redis 时间语义全变体 `-m slow`: **18 passed in 1151s**(19 分 11 秒,真实等待不缩放),含本次新增 4 个
- `make check``lint-imports`: 全绿,**Contracts: 1 kept, 0 broken**
+59 -3
View File
@@ -1,11 +1,11 @@
---
type: schema
node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 22 字段)"
title: "表结构: llm_calls(遥测 26 字段)"
date: 2026-07-20
---
# 表结构: llm_calls(遥测 22 字段)
# 表结构: llm_calls(遥测 26 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -28,6 +28,10 @@ date: 2026-07-20
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -54,7 +58,9 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
## 采样参数口径(2026-07-31,issue #4)
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故当时的统计口径 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
> **该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报 `completion_tokens_details`(MiniMax 这一路实测已停),此时 NULL 只意味着「没上报」而非「没推理」——同一次调用里库拿得到 185 字符推理正文。统计一律改按新列 `thinking_observation` 分组,见下方「推理观测口径」。
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
@@ -77,6 +83,56 @@ SELECT DISTINCT sampling FROM llm_calls
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
```
## 推理观测口径(2026-08-25,issue #16/#17)
`thinking_observation` 是**响应侧的裁定结果**,不是请求侧的声明: 推理正文(`thinking`)非空即 `observed`(正文是事实本身,压倒 usage 明细这一转述);正文空而 `reasoning_tokens > 0``observed`;`reasoning_tokens == 0``absent`(上游明确上报未推理);两个信号双缺为 `unknown`
**`unknown` 不得并进「未推理」**。它是本列存在的全部理由: MiniMax 这一路上游 2026-08-25 起不再返回 `completion_tokens_details`,`reasoning_tokens` 因此恒 NULL,而同一次调用里库拿得到 185 字符推理正文——旧口径 `reasoning_tokens IS NULL OR = 0` 会把这类调用统计成「没推理」。**该旧口径自本版起作废**,统计一律按本列分组。M3 非流式档更极端: 推理已计费(completion 53 vs 关闭档 3)却不回传正文,该档只能是 `unknown`,任何把它读成「没推理」的报表都在撒谎。
按模型看各观测态占比,用于发现某模型从哪天起观测不到推理:
```sql
SELECT model,
thinking_observation,
count(*) AS calls,
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY model), 1) AS pct
FROM llm_calls
WHERE cache_hit = false AND error IS NULL
AND created_at >= now() - interval '7 days'
GROUP BY model, thinking_observation
ORDER BY model, calls DESC;
```
三条限定各有理由: `cache_hit = false``cost`/`cached_prompt_tokens` 同源——缓存命中行原样回放历史观测值,计入即重复计数;`error IS NULL` 排除失败尝试与终态失败行,那些行的本列恒为 `unknown`(无响应可裁定,默认值本身不撒谎),混进来会把「观测不到」的占比整体抬高;时间窗是为了让**变化**可见——某模型的 `unknown` 占比从 0 跳到 100%,正是它停报推理信号的那一天。补列之前写入的历史行本列为 NULL,与 `unknown` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
## 推理档位口径(2026-09-04,issue #20)
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
| 入口 | 有生效源? | 记什么 |
|---|---|---|
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
| `emit_cache_hit` / `emit_terminal_failure` | 无 | 仅 `request.reasoning_effort` |
成功行必须读实发档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
按档位看推理产出,即压测「高档是不是真的多想」的基本查询:
```sql
SELECT model, reasoning_effort,
count(*) AS calls,
round(avg(reasoning_tokens)) AS avg_reasoning_tokens
FROM llm_calls
WHERE cache_hit = false AND error IS NULL AND reasoning_effort IS NOT NULL
GROUP BY model, reasoning_effort
ORDER BY model, calls DESC;
```
## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+32 -2
View File
@@ -22,20 +22,40 @@ from polygateway.errors import (
)
from polygateway.ocr import OcrClient
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
from polygateway.providers import (
DEFAULT_PROFILES,
ProviderProfile,
ThinkingWire,
register_provider,
)
from polygateway.telemetry.schema import telemetry_schema_sql
from polygateway.thinking import (
ThinkingCapability,
ThinkingResolution,
ThinkingUnsupportedError,
get_capability,
register_capability,
resolve_thinking,
)
from polygateway.types import (
EFFORT_ORDER,
Effort,
EmbeddingResponse,
LLMResponse,
OcrLayoutElement,
OcrLayoutResult,
OcrTextResult,
SourceConfig,
TelemetryStatus,
ThinkingObservation,
)
__version__ = "1.1.2"
__version__ = "1.3.3"
__all__ = [
"DEFAULT_PROFILES",
"EFFORT_ORDER",
"Effort",
"AllSourcesExhausted",
"CircuitOpenError",
"EmbeddingClient",
@@ -60,8 +80,18 @@ __all__ = [
"SourceConfig",
"SourceDeadError",
"SourceNotConfiguredError",
"TelemetryStatus",
"ThinkingCapability",
"ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError",
"ThinkingWire",
"TransientError",
"__version__",
"gather_bounded",
"get_capability",
"register_capability",
"register_provider",
"resolve_thinking",
"telemetry_schema_sql",
]
+20 -16
View File
@@ -100,6 +100,22 @@ class InMemoryGate:
streak = max(1, g.reopen_streak)
return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s)
def _remaining(self, g: _SourceGate) -> float:
"""距离**确定**可再试的时刻还有多久(issue #14 的契约定义)。
OPEN 的冷却截止是确定时刻;HALF_OPEN 下探针随时可能出结果,**不存在**
确定时刻, `0.0``0 = 可立即重试` 是库既有约定此前这里返回探针
租约剩余,而租约长度是死锁保护参数(派生自 `2 × 最慢源 timeout`),
"源多久能恢复"无因果关系;它还被喂进源冷却备忘,而备忘 `set_until`
取更晚者不可回退,于是门恢复 CLOSED 后本进程仍跳过该源整整一个租约
三个出口(`try_enter` 拒绝`_snapshot``retry_after_s`)共用本方法,
避免同一语义在三处各算一遍而漂移
"""
if g.state is GateState.OPEN:
return max(0.0, g.open_until - self._now())
return 0.0
def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision:
g.state = GateState.HALF_OPEN
g.probe_owner = owner
@@ -140,7 +156,7 @@ class InMemoryGate:
epoch=g.epoch,
is_probe=False,
probe_owner=None,
retry_after_s=g.open_until - now,
retry_after_s=self._remaining(g),
)
# HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群)
if now >= g.probe_expires:
@@ -152,7 +168,7 @@ class InMemoryGate:
epoch=g.epoch,
is_probe=False,
probe_owner=None,
retry_after_s=g.probe_expires - now,
retry_after_s=self._remaining(g),
)
def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool:
@@ -172,9 +188,7 @@ class InMemoryGate:
state=g.state,
epoch=g.epoch,
failure_count=g.fails,
retry_after_s=max(0.0, g.open_until - self._now())
if g.state is GateState.OPEN
else 0.0,
retry_after_s=self._remaining(g),
)
def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None:
@@ -258,14 +272,4 @@ class InMemoryGate:
"""集合中最早可尝试时间;健康/到期返回 0。"""
if not sources:
raise ValueError("sources 不能为空")
now = self._now()
waits = []
for name in sources:
g = self._gate(name)
if g.state is GateState.OPEN:
waits.append(max(0.0, g.open_until - now))
elif g.state is GateState.HALF_OPEN:
waits.append(max(0.0, g.probe_expires - now))
else:
waits.append(0.0)
return min(waits)
return min(self._remaining(self._gate(name)) for name in sources)
+18 -16
View File
@@ -42,7 +42,8 @@ if state == 'open' and now < open_until then
return {0, state, epoch, 0, '', open_until - now}
end
if state == 'half_open' and now < probe_until then
return {0, state, epoch, 0, '', probe_until - now}
-- 探针在途: 无确定的可再试时刻 0(issue #14,与 memory `_remaining` 同口径)
return {0, state, epoch, 0, '', 0}
end
local next_probe_until = now + tonumber(ARGV[2])
@@ -50,7 +51,7 @@ redis.call('HSET', KEYS[1],
'state', 'half_open',
'probe_owner', ARGV[1],
'probe_until', next_probe_until)
return {1, 'half_open', epoch, 1, ARGV[1], tonumber(ARGV[2])}
return {1, 'half_open', epoch, 1, ARGV[1], 0}
"""
# M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数)
@@ -124,8 +125,6 @@ end
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
"""
@@ -156,8 +155,6 @@ if not matches then
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
end
@@ -255,8 +252,6 @@ end
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
"""
@@ -272,9 +267,6 @@ for _, key in ipairs(KEYS) do
if state == 'open' then
local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0')
remaining = math.max(deadline - now, 0)
elseif state == 'half_open' then
local deadline = tonumber(redis.call('HGET', key, 'probe_until') or '0')
remaining = math.max(deadline - now, 0)
end
if minimum == nil or remaining < minimum then minimum = remaining end
end
@@ -367,7 +359,9 @@ class RedisGate:
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"熔断后端 try_enter 失败: {exc}", scope=self._scope
) from exc
return self._decision(source_name, result)
async def record_success(
@@ -385,7 +379,9 @@ class RedisGate:
try:
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"熔断后端 record_success 失败: {exc}", scope=self._scope
) from exc
return self._update(result)
async def record_failure(
@@ -407,7 +403,9 @@ class RedisGate:
try:
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"熔断后端 record_failure 失败: {exc}", scope=self._scope
) from exc
return self._update(result)
async def release_probe(self, entry: GateDecision) -> GateUpdate:
@@ -419,7 +417,9 @@ class RedisGate:
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
)
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"熔断后端 release_probe 失败: {exc}", scope=self._scope
) from exc
return self._update(result)
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
@@ -429,7 +429,9 @@ class RedisGate:
try:
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope
) from exc
return int(result) / 1000.0
async def aclose(self) -> None:
+15 -5
View File
@@ -247,7 +247,9 @@ class RedisLimiter:
],
)
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"限流后端 try_acquire 失败: {exc}", scope=self._scope
) from exc
if ok != 1:
return None
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
@@ -265,7 +267,9 @@ class RedisLimiter:
try:
await self._release_lua(keys=[gl, sl], args=[lease_id])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"限流后端 release 失败: {exc}", scope=self._scope
) from exc
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
wk = self._window_keys(source_key, window)
@@ -283,7 +287,9 @@ class RedisLimiter:
wk = self._window_keys(source_key, window)
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"限流后端 source_stats 失败: {exc}", scope=self._scope
) from exc
return SourceStats(
inflight=int(res[0]),
rpm_used=max(0, int(res[1])),
@@ -295,14 +301,18 @@ class RedisLimiter:
try:
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"限流后端 mark_progress 失败: {exc}", scope=self._scope
) from exc
async def progress_age_s(self) -> float:
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
try:
res = await self._progress_age_lua(keys=[self._progress_key()])
except RedisError as exc:
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
raise GovernanceBackendError(
f"限流后端 progress_age_s 失败: {exc}", scope=self._scope
) from exc
return float("inf") if int(res) == -1 else int(res) / 1000.0
async def aclose(self) -> None:
+11 -2
View File
@@ -25,14 +25,20 @@ class RedisCache:
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
) from _IMPORT_ERROR
self._client = client
# 注入的客户端归注入方管理: 关掉它会弄死共享同一连接的其他组件
# (与 RedisLimiter/RedisGate 同一纪律)
self._owns_client = False
@classmethod
def from_url(cls, url: str) -> RedisCache:
"""自建并持有 Redis 客户端(aclose 时代关);共享后端请直接注入 client。"""
if aioredis is None:
raise ImportError(
"Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'"
) from _IMPORT_ERROR
return cls(aioredis.from_url(url, decode_responses=True))
cache = cls(aioredis.from_url(url, decode_responses=True))
cache._owns_client = True
return cache
async def get(self, key: str) -> str | None:
return await self._client.get(key)
@@ -41,4 +47,7 @@ class RedisCache:
await self._client.set(key, value, ex=ttl_s)
async def aclose(self) -> None:
await self._client.aclose()
"""幂等释放自建客户端;注入的客户端归注入方管理。"""
if self._owns_client:
self._owns_client = False
await self._client.aclose()
+190 -37
View File
@@ -13,7 +13,7 @@ import hashlib
import json
import random
import time
from typing import TYPE_CHECKING, Any, Literal, TypeVar
from typing import TYPE_CHECKING, Any, Literal
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
@@ -24,8 +24,9 @@ from polygateway.middleware.cache import CacheMW
from polygateway.middleware.retry import RetryMW
from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.ports import TelemetryStatusProvider
from polygateway.pricing import PricingTable
from polygateway.providers import get_capability, get_provider, resolve_thinking
from polygateway.providers import get_provider
from polygateway.sources import (
AdaptivePacer,
HealthAwareSelector,
@@ -33,8 +34,17 @@ from polygateway.sources import (
RoundRobinSelector,
SourceCooldownMemo,
)
from polygateway.thinking import effective_effort, get_capability, resolve_thinking
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
TelemetryStatus,
coerce_effort,
validate_caller_dimensions,
validate_request_overlay,
)
if TYPE_CHECKING:
from collections.abc import Awaitable, Iterable, Mapping
@@ -51,15 +61,14 @@ if TYPE_CHECKING:
TelemetryRecorder,
Transport,
)
from polygateway.providers import ProviderProfile, ThinkingCapability
from polygateway.providers import ProviderProfile
from polygateway.thinking import ThinkingCapability
from polygateway.types import (
BackpressurePolicy,
RetryPolicy,
SourceConfig,
)
_T = TypeVar("_T")
def _guard_thinking(
sources: list[SourceConfig],
@@ -76,20 +85,36 @@ def _guard_thinking(
resolve_thinking(
profile,
get_capability(source.model, table=capabilities),
source.enable_thinking,
# 装配期看不见请求级档位(它逐次调用才产生),故只解源级两层;请求级
# 只能在运行期由 transport 校验(设计 §10 的装配期/运行期分工)
effective_effort(
request_effort=None,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
model=source.model,
# 与 transport 用同一个 fallback,否则配了 nearest 的源会在装配期就被
# 判死,而它在运行期本来是能映射到最近档跑起来的
fallback=source.effort_fallback,
)
def _fingerprint_mark(source: SourceConfig) -> str:
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
"""单源的指纹标记;`enable_thinking` 与 `reasoning_effort` 仅在**表态时**追加。
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动
`reasoning_effort`(issue #20)与 `enable_thinking` 同规则、同理由: 它一旦真正
改变请求体,"把源级档位从 low 改成 max 后重启"就会读到 low 档时缓存的旧响应
两者取值域不相交(`"none"`/`"low"` vs `true`/`false`),故追加进同一个列表也
不会把两种写法摘要成同一身份
"""
parts: list[Any] = [source.model, dict(source.extra_body)]
if source.enable_thinking is not None:
parts.append(source.enable_thinking)
if source.reasoning_effort is not None:
parts.append(source.reasoning_effort)
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
@@ -97,16 +122,25 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
配置级采样参数(`extra_body`)必须参与,否则把 temperature 0 改成 1
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
缓存的旧响应全源者皆未表态时字面量与历史实现逐字相同,不触发存量
缓存冷启动
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking`(issue #5)与
源级 `reasoning_effort`(issue #20)同理: 它一旦真正改变请求体,"关掉推理后
重启"就会读到开着推理时缓存的旧响应全源者皆未表态时字面量与历史实现逐字
相同,不触发存量缓存冷启动
注意本指纹是**装配期**算出的**集合级**身份,覆盖不到逐次调用变化的请求级档位
后者由 `build_cache_key` `reasoning_effort` 参数单独承担(ARCH §7.5)
"""
fingerprint = ",".join(sorted({s.model for s in sources}))
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
# 按 (model, extra_body[, enable_thinking][, reasoning_effort]) 而非源名摘要:
# 语义是"本 scope 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
# 过滤条件必须与 `_fingerprint_mark` 追加的字段逐项对齐: 漏掉一项,只配了该项
# 的源根本进不了 marks,`_fingerprint_mark` 改了也白改
marks = sorted(
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
{
_fingerprint_mark(s)
for s in sources
if s.extra_body or s.enable_thinking is not None or s.reasoning_effort is not None
}
)
if marks:
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
@@ -114,6 +148,55 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
return fingerprint
async def _aclose_component(component: object | None) -> None:
"""关闭一个**自建**组件: 优先 `aclose`,退到同步 `close`,两者皆无则跳过。
退到 `close` 是给 SQLiteRecorder (它只有同步收尾);内存后端两者皆无,
探测后静默跳过三个 client 曾各持一份逐字复制的探测代码,收敛为一处是
所有权纪律能被维持的前提复制即是下一个 bug 的种子(设计 §3.4)
"""
if component is None:
return
aclose = getattr(component, "aclose", None)
if aclose is not None:
await aclose()
return
close = getattr(component, "close", None)
if close is not None:
close()
def _telemetry_status_of(telemetry: TelemetryRecorder | None) -> TelemetryStatus | None:
"""三个 client 共用的状态取值点: 不提供状态的 recorder 一律返回 None。
判定写成 `isinstance(可选端口)` 而不是裸 `getattr`: 两者运行时都是结构检查
(`@runtime_checkable` 按属性存在性判定),差别在**契约有没有名字**端口是
写进 `ports.py` 的公开承诺,下游可以照着实现;散落的 `getattr` 不是,
`aclose` 当年正是被复制成三份鸭子类型探测才漂移出越权关闭(设计 §3.3/§3.4)
"""
if isinstance(telemetry, TelemetryStatusProvider):
return telemetry.telemetry_status
return None
def _mark_owned_components(
client: Any,
*,
limiter: RateLimiter | None,
breaker: ProviderGate | None,
telemetry: TelemetryRecorder | None,
) -> None:
"""工厂置位所有权(三个 client 共用): 传进来的是 None,就说明这一件是工厂自建的。
`RedisLimiter.from_url` 逐字同款私有属性由工厂标记,公共 API 面不变
transport 单列: 三处工厂都没有 transport 注入入口,它恒是自建的
"""
client._owns_transport = True
client._owns_limiter = limiter is None
client._owns_breaker = breaker is None
client._owns_telemetry = telemetry is None
class GatewayClient:
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
@@ -129,8 +212,10 @@ class GatewayClient:
retry: RetryPolicy,
backpressure: BackpressurePolicy,
quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None,
text_cap: int | None = None,
cache: CacheBackend | None = None,
cache_namespace: str | None = None,
cache_ttl_s: int | None = None,
@@ -141,7 +226,11 @@ class GatewayClient:
sleep: Any = asyncio.sleep,
rng: Any = random.random,
) -> None:
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None
emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
if telemetry is not None
else None
)
terminal = RetryMW(
scope=scope,
sources=sources,
@@ -152,6 +241,7 @@ class GatewayClient:
retry=retry,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
cooldown_memo=SourceCooldownMemo(now=now),
# AIMD ceiling 尊重源级静态并发上限(独立核验 I1: 不得静默钳制大于 64 的配置)
pacer=AdaptivePacer(
@@ -192,8 +282,29 @@ class GatewayClient:
self._transport = transport
self._telemetry = telemetry
self._cache = cache
# limiter/breaker 交给 RetryMW 之后仍须自持引用,否则 aclose 触达不到
# 自建的 redis 客户端(设计 §3.4 记录的现存泄漏)
self._limiter_backend = limiter
self._breaker_backend = breaker
# 所有权默认"不拥有": `__init__` 是全量注入路径,经它传入的一切都是
# 外部资源,关掉别人的连接会弄死共享同一后端的其他 client(ARCH §7.7 R5)。
# 只有工厂在真正自建时才置 True
self._owns_transport = False
self._owns_telemetry = False
self._owns_cache = False
self._owns_limiter = False
self._owns_breaker = False
self._closed = False
@property
def telemetry_status(self) -> TelemetryStatus | None:
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
`aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)
"""
return _telemetry_status_of(self._telemetry)
async def chat(
self,
messages: list[dict[str, Any]],
@@ -205,12 +316,24 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None,
reasoning_effort: Effort | str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` ),优先级
高于源级 `extra_body`低于结构化输出的注入带默认值的 keyword-only
参数不影响既有调用点(issue #4)。
`reasoning_effort` 是本次调用的推理档位,优先级高于源级 `REASONING_EFFORT`
`ENABLE_THINKING`(设计 §4.2)`None` **不表态**(随源级配置),
`Effort.NONE`("要求不推理")严格区分裸字符串(`"low"`)也收,在此归一成
`Effort`,非法值当场 `ValueError` `SourceConfig` 那条装配路同口径
`tenant_id` `meta` 是调用方自定义维度,只进遥测**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS可进复合索引),后者是任意 KV 容器(issue #11)。
"""
if structured is not None and not self._structured_available:
raise ImportError(
@@ -221,6 +344,21 @@ class GatewayClient:
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
)
# 同样必须在洋葱之外归一: 档位一路要被 `is Effort.NONE` 身份比较,裸字符串
# 进去会在 transport 的错误路径上抛 `AttributeError`——那不属错误四分类,
# 会穿透 `except ThinkingUnsupportedError` 与 RetryMW 的分类捕获(库铁律
# 「错误分类驱动」)。归一失败是调用方编程错误,抛裸 ValueError 不进洋葱
effort = (
None
if reasoning_effort is None
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
)
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -231,27 +369,30 @@ class GatewayClient:
stream=stream,
overlay=sampling,
sampling=sampling,
reasoning_effort=effort,
tenant_id=dimension_tenant_id,
meta=dimensions,
)
return await self._handler(request)
async def aclose(self) -> None:
"""幂等释放: transport 连接池、遥测连接、缓存客户端。"""
"""幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。
注入的组件一律不碰它们可能被别的 client 共享,关掉即越权
"""
if self._closed:
return
self._closed = True
transport_aclose = getattr(self._transport, "aclose", None)
if transport_aclose is not None:
await transport_aclose()
telemetry_aclose = getattr(self._telemetry, "aclose", None)
if telemetry_aclose is not None:
await telemetry_aclose() # Postgres 等异步后端
else:
telemetry_close = getattr(self._telemetry, "close", None)
if telemetry_close is not None:
telemetry_close()
cache_aclose = getattr(self._cache, "aclose", None)
if cache_aclose is not None:
await cache_aclose()
if self._owns_transport:
await _aclose_component(self._transport)
if self._owns_telemetry:
await _aclose_component(self._telemetry)
if self._owns_cache:
await _aclose_component(self._cache)
if self._owns_limiter:
await _aclose_component(self._limiter_backend)
if self._owns_breaker:
await _aclose_component(self._breaker_backend)
async def __aenter__(self) -> GatewayClient:
return self
@@ -279,20 +420,22 @@ class GatewayClient:
profiles = [get_provider(s.provider, registry=registry) for s in sources]
_guard_thinking(sources, profiles, capabilities)
strategy, escalation = _build_structured(profiles)
return cls(
client = cls(
scope=settings.scope,
sources=sources,
selector=_build_selector(settings.selector, rng=rng),
limiter=limiter or _build_limiter(settings, sources),
breaker=breaker or _build_breaker(settings),
limiter=limiter if limiter is not None else _build_limiter(settings, sources),
breaker=breaker if breaker is not None else _build_breaker(settings),
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
retry=settings.retry,
backpressure=settings.backpressure,
quota_full=settings.quota_full,
circuit_open=settings.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(settings),
pricing=PricingTable.from_file(settings.pricing_path)
if settings.pricing_path is not None
else None,
text_cap=settings.telemetry_text_cap,
cache=cache if cache is not None else _build_cache(settings),
cache_namespace=settings.cache_namespace,
cache_ttl_s=settings.cache_ttl_s,
@@ -300,6 +443,9 @@ class GatewayClient:
structured_escalation=escalation,
structured_max_retries=settings.structured_max_retries,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
return client
@classmethod
def from_env(
@@ -381,11 +527,18 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
from polygateway.telemetry.postgres import PostgresRecorder
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
return PostgresRecorder(settings.telemetry_pg_dsn)
return PostgresRecorder(
settings.telemetry_pg_dsn,
auto_migrate=settings.telemetry_auto_migrate,
pool_max=settings.telemetry_pg_pool_max,
write_timeout_s=settings.telemetry_pg_write_timeout_s,
)
from polygateway.telemetry.sqlite import SQLiteRecorder
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
return SQLiteRecorder(settings.telemetry_sqlite_path)
return SQLiteRecorder(
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
)
def _build_structured(
@@ -410,7 +563,7 @@ def _build_structured(
return None, None
async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> list[_T]:
async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]:
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
语义与 `asyncio.gather` 默认一致: 结果保序首个异常上抛;仅增加并发上限
@@ -419,7 +572,7 @@ async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> l
raise ValueError("concurrency 必须 ≥ 1")
sem = asyncio.Semaphore(concurrency)
async def _run(aw: Awaitable[_T]) -> _T:
async def _run(aw: Awaitable[T]) -> T:
async with sem:
return await aw
+176
View File
@@ -25,6 +25,7 @@ from polygateway.types import (
GlobalLimits,
RetryPolicy,
SourceConfig,
coerce_effort,
)
if TYPE_CHECKING:
@@ -43,6 +44,11 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TTFT_TIMEOUT_S": ("ttft_timeout_s", "float"),
"INTER_TOKEN_TIMEOUT_S": ("inter_token_timeout_s", "float"),
"ENABLE_THINKING": ("enable_thinking", "bool"),
# 档位两键(issue #20);值域校验分工: 档位在此(解析即校验,报错点得出 env 键名),
# fallback 交给 SourceConfig 构造期(那道同时覆盖构造函数注入与 dataclasses.replace)
"REASONING_EFFORT": ("reasoning_effort", "effort"),
# 归一化(strip+lower)在 SourceConfig 构造期,与值域校验同处一点,故这里是裸 "str"
"EFFORT_FALLBACK": ("effort_fallback", "str"),
"MISSING_DONE": ("missing_done", "str"),
"TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"),
@@ -50,11 +56,28 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
# "排队等自己的份额"(必然轮到),熔断开路是"等源恢复"(未必恢复),故分列两键
_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
# 遥测 schema 档位(issue #13): auto 允许 recorder 给旧表 ALTER 补列,manual 不发 DDL
_SCHEMA_MODES = frozenset({"auto", "manual"})
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
# 遥测池的资源占用与写入预算(issue #15);缺省只写在这里,recorder 侧是必填参数
_POOL_MAX_KEY = "PGW_TELEMETRY_PG_POOL_MAX"
_WRITE_TIMEOUT_KEY = "PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"
# 4 条实测约 15.6 行/秒(跨内网 RTT ≈ 123ms 的实验室 PG,50 行并发批耗时 3.2s)。
# **不要按 `pool_max / RTT` 折算**——那会乐观一倍(一次 INSERT 的往返比一次
# SELECT 1 重)。够单 client 十余并发;闲时占 0 条
_DEFAULT_PG_POOL_MAX = 4
# 实测稳态写入 123ms、首次含建连 513ms;5s 宽松且**有界**
_DEFAULT_PG_WRITE_TIMEOUT_S = 5.0
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
_DEFAULT_STALL_WINDOW_S = 300.0
@@ -76,6 +99,10 @@ def _cast(raw: str, kind: str, key: str) -> object:
if lowered in ("0", "false", "no", "off"):
return False
raise ValueError(f"非法布尔值: {raw!r}")
if kind == "effort":
# 归一化只有一份实现(`types.coerce_effort`),env 路与两条装配路同口径;
# origin 传空串是因为 env 键名由下面统一的"配置 X 解析失败"补上
return coerce_effort(raw, origin="")
if kind == "json":
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
parsed = json.loads(raw)
@@ -123,6 +150,9 @@ class GatewaySettings:
backpressure: BackpressurePolicy
selector: str
quota_full: str
# 熔断全拒时是当场判死还是等冷却过去(issue #14);缺省 fail_fast 保持
# 存量下游的控制流不变,单源 scope 应显式配 wait
circuit_open: str
limiter_backend: str
breaker_backend: str
cache_backend: str
@@ -131,6 +161,23 @@ class GatewaySettings:
telemetry_backend: str
telemetry_sqlite_path: str | None
telemetry_pg_dsn: str | None
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);env 的三态
# 派生只写在 `_load_schema_mode` 一处,不与 recorder 的类签名漂移。
# backend=none 时恒 False 这条跨字段不变量则由 `_validate_telemetry`
# 把关,对直接构造与 `dataclasses.replace` 同样生效
telemetry_auto_migrate: bool
# 遥测落库正文的字符上限(issue #12);None = 不截断,与本字段出现之前逐字节相同。
# 缺省不截断是人类决策: 截断后的遥测不再是审计证据、也无法用于复现与重放,而
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
# `dataclasses.replace` 与 env 三条路一并覆盖
telemetry_text_cap: int | None
# 遥测池对外声明的资源占用上限与整次写入的硬预算(issue #15)。库内每一处外部
# 资源都按需建连,唯独遥测池此前预占 10 条(asyncpg 默认 `min_size`),共享实例
# 余量紧张时先倒下的必然是它。这两个字段是库对自己占用的**显式表态**:
# 稳态并发上限 = `pool_max`,闲时 0 条;单次写入(准备+取连接+执行)≤ 预算。
# 值域由 `_validate_telemetry` 把关,直接构造、`dataclasses.replace` 与 env 三条路一致
telemetry_pg_pool_max: int
telemetry_pg_write_timeout_s: float
redis_url: str | None
pricing_path: str | None
structured_max_retries: int
@@ -188,6 +235,7 @@ class GatewaySettings:
("telemetry_backend", _TELEMETRY_BACKENDS),
("selector", _SELECTORS),
("quota_full", _QUOTA_FULL),
("circuit_open", _CIRCUIT_OPEN),
):
value = getattr(self, field)
if value not in allowed:
@@ -211,7 +259,24 @@ class GatewaySettings:
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果但不静默`from_env`
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
他有权知道库动了他给的值
`telemetry_auto_migrate` 同理归一化而非报错: backend=none 时根本没有
recorder 消费它,True 是个自相矛盾却无害的状态`from_env` 那条路的派生
已经给出 False,归一化是为了直接构造与 `dataclasses.replace` 也一致
不变量挂在构造期,才不用每加一个装配工厂就多一处要同步
`telemetry_text_cap` 的值域则是**报错**而非归一化: 0 与负数都不是"不截断"
的写法(不截断写 None),把它们悄悄改成 None 等于用默认值掩盖调用方的错误
报错文本同时点出字段名与 env 键名,两条装配路的调用方各看得懂自己那套
"""
if self.telemetry_text_cap is not None and self.telemetry_text_cap <= 0:
raise ValueError(
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
)
self._validate_telemetry_pool()
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
object.__setattr__(self, "telemetry_auto_migrate", False)
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
if self.telemetry_backend != "postgres":
@@ -228,6 +293,24 @@ class GatewaySettings:
)
object.__setattr__(self, "telemetry_pg_dsn", stripped)
def _validate_telemetry_pool(self) -> None:
"""遥测池两个标量的值域(issue #15);与 backend 无关,三条装配路一并覆盖。
不按 `telemetry_backend == "postgres"` 才校验: 值域错就是错,提前拦住
比等到有人把 backend 切成 postgres 时才炸更接近"缺失关键配置直接报错"
报错文本同时点字段名与 env 键名(两类调用方各看得懂自己那套)
"""
if self.telemetry_pg_pool_max < 1:
raise ValueError(
f"telemetry_pg_pool_max({_POOL_MAX_KEY})必须 >= 1: "
f"{self.telemetry_pg_pool_max};0 条上限等于永远取不到连接,遥测会全灭"
)
if self.telemetry_pg_write_timeout_s <= 0:
raise ValueError(
f"telemetry_pg_write_timeout_s({_WRITE_TIMEOUT_KEY})必须 > 0: "
f"{self.telemetry_pg_write_timeout_s};预算 0 会让每一行当场超预算被丢弃"
)
def _validate_lease(self) -> None:
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
slowest = max(s.timeout_s for s in self.sources)
@@ -289,6 +372,7 @@ class GatewaySettings:
backpressure=_load_backpressure(scope_u, env),
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
**_load_pgw(env),
)
@@ -434,6 +518,7 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
redis_url = env.get("REDIS_URL") or None
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
auto_migrate = _load_schema_mode(env, telemetry_backend)
return {
"limiter_backend": limiter_backend,
"breaker_backend": breaker_backend,
@@ -444,6 +529,10 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
if telemetry_backend == "sqlite"
else None,
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
"telemetry_auto_migrate": auto_migrate,
"telemetry_text_cap": _load_text_cap(env),
"telemetry_pg_pool_max": _load_pool_max(env),
"telemetry_pg_write_timeout_s": _load_write_timeout(env),
"redis_url": redis_url,
"pricing_path": env.get("PGW_PRICING_PATH") or None,
"structured_max_retries": _load_structured_retries(env),
@@ -451,6 +540,93 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
}
def _load_schema_mode(env: Mapping[str, str], telemetry_backend: str) -> bool:
"""把 `PGW_TELEMETRY_SCHEMA_MODE` 的三态解成 `telemetry_auto_migrate`(issue #13)。
三态: 键未设 按后端**不对称**派生;显式 auto/manual 两侧都可覆盖
不对称的理由是两个后端的风险量级不同: SQLite 是下游自己的本地文件(没有
DBA没有迁移工具没有第二个系统碰它),ALTER 是毫秒级元数据操作,要求
手工跑 SQL 是给零运维场景强加运维步骤;PG 是共享的生产表,ALTER
ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询,而遥测是业务
路径上的内联 await
`_load_choice` default,不能直接用来读这个键default 会把"未设"
"设成默认值"抹平成同一种,三态就塌回两态,后端派生也就再没机会生效
先用 `_first` "设没设",确认设了才交给 `_load_choice` 做值域校验(错误
信息点出 env 键名这件事仍由它负责)
Args:
env: 已合并的环境映射
telemetry_backend: 已校验过值域的遥测后端名
Returns:
recorder 是否获准给旧表自动 ALTER 补列;backend=none 时无人消费,
构造期守卫会再把它归一化为 False
"""
if _first(env, _SCHEMA_MODE_KEY) is None:
return telemetry_backend == "sqlite"
return _load_choice(env, _SCHEMA_MODE_KEY, _SCHEMA_MODES, "auto") == "auto"
def _load_text_cap(env: Mapping[str, str]) -> int | None:
"""读 `PGW_TELEMETRY_TEXT_CAP`(issue #12);键未设即 None = 不截断。
与相邻的 `PGW_TELEMETRY_SCHEMA_MODE` 不同,这个键是**二态**而非三态:
"未设"本身就是最终答案(不截断),没有需要按后端派生的第二种缺省,故不必像
那边一样先探"设没设"再分两条路取值,读到什么解什么即可
值域(> 0)刻意不在此处判: 构造期守卫那道同时覆盖直接构造与
`dataclasses.replace`,而报错文本已点出本键名,env 路的调用方不会看丢
Args:
env: 已合并的环境映射
Returns:
遥测正文的字符上限;键未设或为空串时返回 None(不截断)
"""
found = _first(env, _TEXT_CAP_KEY)
if found is None:
return None
return int(_cast(found[1], "int", found[0]))
def _load_pool_max(env: Mapping[str, str]) -> int:
"""读 `PGW_TELEMETRY_PG_POOL_MAX`(issue #15);未设即缺省 4。
`_load_text_cap` 同为二态键,只是"未设"落到一个具体缺省而非 None:
池上限没有"不设上限"这一档不表态就是继承第三方默认值,而那正是本 issue
的病灶值域(>= 1)留给构造期守卫,它同时覆盖直接构造与 `dataclasses.replace`
Args:
env: 已合并的环境映射
Returns:
遥测池允许的最大连接数
"""
found = _first(env, _POOL_MAX_KEY)
if found is None:
return _DEFAULT_PG_POOL_MAX
return int(_cast(found[1], "int", found[0]))
def _load_write_timeout(env: Mapping[str, str]) -> float:
"""读 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15);未设即缺省 5.0 秒。
这个值同时是 connectacquire 与整次写入的上界: 遥测是业务路径上的内联
await,"不设预算"不是一个允许存在的档位(铁律"丢一条 < 拖垮调用")
Args:
env: 已合并的环境映射
Returns:
单次遥测写入的硬预算()
"""
found = _first(env, _WRITE_TIMEOUT_KEY)
if found is None:
return _DEFAULT_PG_WRITE_TIMEOUT_S
return float(_cast(found[1], "float", found[0]))
def _strip_dsn_driver(dsn: str) -> str:
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
scheme, sep, rest = dsn.partition("://")
+144 -102
View File
@@ -21,14 +21,14 @@ import random
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from loguru import logger
from polygateway.client import _aclose_component, _telemetry_status_of
from polygateway.config import EmbeddingSettings
from polygateway.errors import (
AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError,
PolyGatewayError,
RequestRejectedError,
@@ -37,16 +37,18 @@ from polygateway.errors import (
SourceNotConfiguredError,
TransientError,
)
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
ChatRequest,
EmbeddingResponse,
LLMResponse,
TelemetryStatus,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
if TYPE_CHECKING:
@@ -101,8 +103,10 @@ class EmbeddingClient:
retry: RetryPolicy,
backpressure: BackpressurePolicy,
quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None,
text_cap: int | None = None,
batch_size: int,
normalize: bool = False,
expected_dim: int | None = None,
@@ -112,31 +116,50 @@ class EmbeddingClient:
) -> None:
if batch_size < 1:
raise ValueError("batch_size 必须 ≥ 1")
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1")
self._scope = scope
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
self._selector = selector
self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None
self._emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
)
self._telemetry = telemetry
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
# 引用才关得到自建的 redis 客户端(设计 §3.4)
self._limiter_backend = limiter
self._breaker_backend = breaker
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
self._owns_transport = False
self._owns_telemetry = False
self._owns_limiter = False
self._owns_breaker = False
self._pricing = pricing
self._batch_size = batch_size
self._normalize = normalize
self._expected_dim = expected_dim
self._memo = SourceCooldownMemo(now=now)
self._now = now
self._sleep = sleep
self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
now=now,
sleep=sleep,
rng=rng,
)
self._closed = False
async def embed(
@@ -145,10 +168,22 @@ class EmbeddingClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> EmbeddingResponse:
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。"""
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
本次调用而非某一批,故每批的遥测行都带同一份维度
"""
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
if not texts:
return EmbeddingResponse(
vectors=[],
@@ -167,7 +202,11 @@ class EmbeddingClient:
for start in range(0, len(texts), self._batch_size):
outcomes.append(
await self._embed_batch(
texts[start : start + self._batch_size], session_id, parent_call_id
texts[start : start + self._batch_size],
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
)
)
return self._merge(outcomes)
@@ -175,19 +214,26 @@ class EmbeddingClient:
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
async def _embed_batch(
self, batch: list[str], session_id: str | None, parent_call_id: str | None
self,
batch: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome:
fails = 0
reasons: dict[str, str] = {}
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
clock = StallClock(self._now)
while True:
picked, gate_rejections = await self._pick_runnable(reasons)
picked, gate_rejections = await self._admission.pick(reasons, {})
if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock)
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue
async with clock.attempting():
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id)
outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _BatchOutcome):
return outcome
fails += 1
@@ -201,62 +247,6 @@ class EmbeddingClient:
if not outcome.immediate:
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
async def _pick_runnable(
self, reasons: dict[str, str]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
for cand in self._selector.order(self._sources, stats):
if self._memo.active(cand.name):
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
if entry is None:
await self._settle_and_release(permit, 0)
if entry.allowed:
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit, 0)
return None, gate_rejections
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
stall = self._bp.stall_window_s
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
async def _attempt(
self,
batch: list[str],
@@ -266,6 +256,8 @@ class EmbeddingClient:
reasons: dict[str, str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4())
started = self._now()
@@ -286,17 +278,45 @@ class EmbeddingClient:
await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress())
latency_ms = int((self._now() - started) * 1000)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _BatchOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise
except asyncio.CancelledError:
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
batch, source, call_id, started, session_id, parent_call_id, error="cancelled"
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
)
raise
except (SourceDeadError, TransientError) as exc:
@@ -307,10 +327,20 @@ class EmbeddingClient:
if not dead:
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedBatch(exc, immediate=dead)
finally:
await self._settle_and_release(permit, actual)
await settle_and_release(permit, actual)
# —— 辅助 ——
@@ -332,17 +362,6 @@ class EmbeddingClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc)
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("embedding permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit(
self,
batch: list[str],
@@ -351,16 +370,22 @@ class EmbeddingClient:
started: float,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: EmbeddingTransportResult | None = None,
error: object | None = None,
) -> None:
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
)
response = None
if result is not None:
@@ -386,6 +411,9 @@ class EmbeddingClient:
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
reasoning_applies=False,
)
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
@@ -430,21 +458,28 @@ class EmbeddingClient:
known = [c for c in costs if c is not None]
return sum(known) if known else None
@property
def telemetry_status(self) -> TelemetryStatus | None:
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
`aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)
"""
return _telemetry_status_of(self._telemetry)
async def aclose(self) -> None:
"""幂等释放 transport 连接池与遥测连接(与 GatewayClient 对称)。"""
"""幂等释放**自建**资源(与 GatewayClient 对称);注入的组件一律不碰"""
if self._closed:
return
self._closed = True
transport_aclose = getattr(self._transport, "aclose", None)
if transport_aclose is not None:
await transport_aclose()
telemetry_aclose = getattr(self._telemetry, "aclose", None)
if telemetry_aclose is not None:
await telemetry_aclose()
else:
telemetry_close = getattr(self._telemetry, "close", None)
if telemetry_close is not None:
telemetry_close()
if self._owns_transport:
await _aclose_component(self._transport)
if self._owns_telemetry:
await _aclose_component(self._telemetry)
if self._owns_limiter:
await _aclose_component(self._limiter_backend)
if self._owns_breaker:
await _aclose_component(self._breaker_backend)
async def __aenter__(self) -> EmbeddingClient:
return self
@@ -470,30 +505,37 @@ class EmbeddingClient:
_build_limiter,
_build_selector,
_build_telemetry,
_mark_owned_components,
)
from polygateway.pricing import PricingTable
from polygateway.transports.openai_compat import OpenAICompatTransport
gw = settings.gateway
sources = list(gw.sources)
return cls(
client = cls(
scope=gw.scope,
sources=sources,
selector=_build_selector(gw.selector),
limiter=limiter or _build_limiter(gw, sources),
breaker=breaker or _build_breaker(gw),
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
breaker=breaker if breaker is not None else _build_breaker(gw),
transport=OpenAICompatTransport(registry=registry),
retry=gw.retry,
backpressure=gw.backpressure,
quota_full=gw.quota_full,
circuit_open=gw.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
pricing=PricingTable.from_file(gw.pricing_path)
if gw.pricing_path is not None
else None,
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
# 一半不受控(issue #12)
text_cap=gw.telemetry_text_cap,
batch_size=settings.batch_size,
normalize=settings.normalize,
expected_dim=settings.expected_dim,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
return client
@classmethod
def from_env(
+45 -4
View File
@@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset(
class PolyGatewayError(Exception):
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。"""
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
`body_text` ** 2xx 响应体的摘要**网关拒绝这次调用时说的话(issue #10)。
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
============== ==================================================
``body_text`` ** 2xx** HTTP 错误响应体: 对方**拒绝**的理由
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
============== ==================================================
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来
"对方说了什么""它属于哪一类"正交scope 级错误(`GatewayUnavailableError`
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
且可能包含网关对请求的回显库不做脱敏: 它不知道下游哪些字段敏感,
猜测式脱敏只会同时丢掉诊断价值与安全性
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)
"""
def __init__(
self,
@@ -44,11 +63,13 @@ class PolyGatewayError(Exception):
source_name: str | None = None,
status_code: int | None = None,
operation: str | None = None,
body_text: str = "",
) -> None:
super().__init__(message)
self.source_name = source_name
self.status_code = status_code
self.operation = operation
self.body_text = body_text
class TransientError(PolyGatewayError):
@@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
class RequestRejectedError(PolyGatewayError):
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。"""
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分下游曾观测到
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`
耗时远低于任何成功调用请求在推理开始前就被挡了本库仍按确定性失败处理
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
形态不同
"""
class ResultInvalidError(PolyGatewayError):
@@ -86,9 +116,20 @@ class ResultInvalidError(PolyGatewayError):
class GatewayUnavailableError(PolyGatewayError):
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式)
"""scope 级暂时不可用: 库的**调用级**预算已经耗尽
`retry_after_s` 非可选(0 = 可立即重试), CHS ProviderUnavailableError
**职责边界(issue #14)**: 调用级的重试、退避、换源、等待冷却全部在库内,
不需要下游再写一层两边各写一份必然漂移(库调了退避曲线而下游不知道,
下游改了等待上限而库的遥测算不进去),漂移之后"这次调用到底等了多久、
试了几次"就没有单一事实源答得出来。本异常表示那份预算(重试预算或 stall
预算)已经用完下游据此再投是**任务级重试**,与调用级重试语义不同,
业务自行在库外包(ARCH §7.2 单层重试原则)
熔断开路时是当场抛本类还是先等冷却过去, `{SCOPE}__CIRCUIT_OPEN`
决定(缺省 fail_fast;单源 scope 建议配 wait)
`retry_after_s` 非可选,语义是"距离**确定**可再试的时刻还有多久";
`0` 表示不存在确定的等待时刻(可立即重试), CHS ProviderUnavailableError
"""
def __init__(
+287
View File
@@ -0,0 +1,287 @@
"""SourceAdmission: 一次尝试的准入编排,三条治理循环(chat/embedding/ocr)共用一份。
**收敛缘由(issue #14)**: 本模块的两个方法此前在 `middleware/retry.py`、
`embedding.py``ocr.py` 各存一份逐字复制(后两份是第一份的子集)准入语义
一直在演进issue #8 改过 stall 口径、M2.5 加过 AIMD pacer、issue #14 要加
熔断等待档每演进一次就要三处同步,漏一处即行为分叉三份复制正是库铁律
痛斥的那种模式(遥测"三项目 4 处复制"的教训),只不过这次发生在库内部
**职责边界**: 只管"挑出一个可跑的源""一个都挑不出来时怎么办";一次尝试
本身(transport 调用记账写回逐次遥测)仍归各循环的 `_attempt`
**共享而非持有**: `QuotaGate`/`BreakerGate`/`AdaptivePacer`/`SourceSelector`
调用方构造后传入**同一实例**三处 `_attempt` 仍要用它们做记账写回与
`pacer.leave()`pacer 尤其不能各建一个: 它有在途计数,分裂成两个计数器会让
`admit`/`enter` `leave` 记到不同账上`SourceCooldownMemo` 只被准入消费,
由本类独占
"""
from __future__ import annotations
import asyncio
import random
import time
import uuid
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.errors import AllSourcesExhausted, CircuitOpenError
from polygateway.sources import SourceCooldownMemo
# 两个准入策略键共用的值域;校验只此一处,不在各客户端重复
_POLICIES = frozenset({"wait", "fail_fast"})
if TYPE_CHECKING:
from collections.abc import Callable
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock
from polygateway.ports import GateDecision, Permit, SourceSelector
from polygateway.sources import AdaptivePacer
from polygateway.types import BackpressurePolicy, SourceConfig
async def settle_and_release(permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。
三条循环的 `_attempt` 与本模块的准入拒绝路径共用这一份(此前三处逐字复制,
warning 文案不同)
"""
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
def _demote_call_failures(
ordered: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float] | None,
) -> list[SourceConfig]:
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
可信替代 = 某未失败候选 health 0.5 × 失败源 health异构池里健康源
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)
无健康视图(round_robin )保持无条件降权(冷启动保护)
`attempt_fails` 为空时恒等返回原列表对象embedding/ocr 不维护调用内
失败计数,故对它们这一步是零成本的空操作,无需在调用侧加分支
"""
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
if not demoted or len(demoted) == len(ordered):
return ordered
if health is None:
return _move_to_tail(ordered, demoted)
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
names = {d.name for d in demoted}
return [s for s in ordered if s.name not in names] + demoted
def _health_gated_reorder(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
if not demoted:
return ordered
names = {d.name for d in demoted}
rest = [s for s in ordered if s.name not in names]
return _insert_after_credible(rest, demoted, health)
def _insert_after_credible(
rest: list[SourceConfig],
demoted: list[SourceConfig],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源"""
bar = 0.5 * max(health(d.name) for d in demoted)
credible = [s for s in rest if health(s.name) >= bar]
junk = [s for s in rest if health(s.name) < bar]
return credible + demoted + junk
def _credible_demotions(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
class SourceAdmission:
"""准入编排器(CHS `governance.py:107-285` 同款);时钟/睡眠/随机全部注入。"""
def __init__(
self,
*,
scope: str,
sources: list[SourceConfig],
selector: SourceSelector,
quota: QuotaGate,
breaker: BreakerGate,
backpressure: BackpressurePolicy,
quota_full: str,
circuit_open: str,
memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None,
health_view: Callable[[str], float] | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], object] = asyncio.sleep,
rng: Callable[[], float] = random.random,
) -> None:
for name, value in (("quota_full", quota_full), ("circuit_open", circuit_open)):
if value not in _POLICIES:
raise ValueError(f"{name} 必须是 wait|fail_fast: {value!r}")
self._scope = scope
self._sources = sources
self._selector = selector
self._quota = quota
self._breaker = breaker
self._bp = backpressure
self._quota_full = quota_full
self._circuit_open = circuit_open
self._memo = memo or SourceCooldownMemo(now=now)
self._pacer = pacer
self._health_view = health_view
self._now = now
self._sleep = sleep
self._rng = rng
# —— 选源与准入(CHS _pick_runnable 120-167)——
async def pick(
self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered:
if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
if self._pacer is not None and not self._pacer.admit(cand.name):
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
reasons.setdefault(cand.name, "adaptive_paced")
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
if entry is None:
await settle_and_release(permit, 0)
if entry.allowed:
if self._pacer is not None:
self._pacer.enter(cand.name)
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await settle_and_release(permit, 0)
return None, gate_rejections
# —— 背压与 stall 判死(CHS governance.py:270-285)——
async def stalled(self, clock: StallClock) -> bool:
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
无进展**同时**超窗才判死本地 monotonic 与后端时钟刻意不混用
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
本地未超窗就不问后端,省一次 Redis 往返
"""
stall = self._bp.stall_window_s
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
async def on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
"""一个源都挑不出来时的处置: **按拒绝原因分派**到各自的策略。
分派而非串行是硬要求(issue #14): 串行写法下 `circuit_open=wait` 不抛
之后会径直掉进配额分支,`quota_full=fail_fast` 的调用方于是收到一个
`reason=quota_exhausted` 的异常而配额其实是满的,坏的是熔断门
"""
names = tuple(s.name for s in self._sources)
if gate_rejections == len(self._sources):
# 全部因熔断类原因(门开路 / 本地冷却备忘)被拒
if self._circuit_open == "fail_fast":
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
# wait: 保护作用完整保留(这一轮照样一个请求都不发),改变的只是
# 调用方当场死还是排队等——多源可换源故 fail-fast 对,单源无源可换
hint = await self._breaker.retry_after_s(names)
else:
# 至少一个源是被配额/AIMD 挡的,归 quota_full 管
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
hint = 0.0
if await self.stalled(clock):
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
nap = self._nap(hint, clock)
if hint > 0:
logger.info("熔断开路等待 {:.1f}s 后重试(scope={}, 原因={})", nap, self._scope, reasons)
await self._sleep(nap)
def _nap(self, hint: float, clock: StallClock) -> float:
"""本轮等待多久。**必须在 `stalled()` 判定之后调用**(预算可能已耗尽)。
`hint > 0`(熔断开路有确定的冷却截止)时睡到那个时刻,而不是按
`poll_interval` 空转60 秒冷却用 10ms 轮询是 6000 次空转,内存后端
只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用抖动****
而非缩放(既有 quota 路径是 `[0.5p, 1.0p]`): 对一个确定的截止时刻提前
醒来必然被再拒一次,白跑一趟
两档都夹到剩余 stall 预算,故单次调用的最坏墙钟是 `stall_window_s`
加一个 poll 间隔,不随 `max_cooldown_s` 漂移多加的那一格是因为
`stalled()` 判据是 `>` 而非 `>=`恰好睡到窗口边界不判死,留这一格
让下一轮必定判死`hint == 0` 时整个式子退化为既有的 jitter 轮询
"""
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
wait = hint + jitter if hint > 0 else jitter
# 下界取 jitter 而非 poll_interval: 既有 quota 轮询是 [0.5p, 1.0p],用
# poll_interval 兜底会把 rng→0 那半边抬上去。预算为负时(本地已超窗但
# 全局仍在出餐,故 stalled() 不判死)靠它退回正常轮询节奏,不忙循环。
return max(jitter, min(wait, budget))
+70 -1
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger
from polygateway.types import ChatRequest, LLMResponse
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -28,6 +28,54 @@ _KEY_PREFIX = "pgw:cache:"
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
def _coerce_observation(raw: Any) -> ThinkingObservation:
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
方向选择的理由: `_rehydrate` JSON 里的**新字段**已经是宽容的(先按
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的真实场景是
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
这些条目判成未命中,就会每次真打网关随后覆写回旧值,两个版本互相打对方的
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)一个纯
可观测性字段不该有能力废掉内容完好的缓存响应"整条作废"留给真正破坏内容
完整性的失败(JSON 坏了结构化重建不过)
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
读不懂的取值,这是唯一诚实的说法
"""
try:
return ThinkingObservation(raw)
except ValueError:
logger.warning(
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
raw,
)
return ThinkingObservation.UNKNOWN
def _coerce_applied_effort(raw: Any) -> Effort | None:
"""缓存里的档位字符串 → 枚举;域外取值降级为 `None`,**不作废整条缓存**。
`_coerce_observation` 同源同向,理由逐条相同: 多项目共用一个 Redis ,
先升级的进程可能写入本版没有的档位名,未升级的进程若把这些条目判成未命中,
两个版本就会互相打对方的缓存归因字段不该有能力废掉内容完好的响应
降级到 `None` 而不是别的档: 它的语义是"库不知道这次跑在哪档",对一个读不懂
的取值这是唯一诚实的说法随便挑一档等于替上游声称了一件它没说过的事
"""
if raw is None:
return None
try:
return Effort(raw)
except ValueError:
logger.warning(
"缓存条目的 applied_effort 取值 {!r} 不在本版档位词汇内(多半由更新版本的"
"进程写入),已降级为 None;响应内容照常复活——归因字段不作废缓存",
raw,
)
return None
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
@@ -58,12 +106,21 @@ def build_cache_key(
salt: str | None,
*,
sampling: Mapping[str, Any] | None = None,
reasoning_effort: Effort | None = None,
) -> str:
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
`sampling` **非空**时参与( salt "仅非 None"不同空串是有意义的
salt,而空采样参数与不传无语义差别)它必须进 key: 否则同 messages 5
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
`reasoning_effort` **请求级**档位(issue #20),仅非 `None` 时参与。它不能靠
`model_fingerprint` 代劳: 后者是**装配期**算出的集合级指纹,一次调用改档位不会
让它变一个字节;不进 key 则同 messages low max 互相命中, issue #4
5 seed 全命中同一响应的逐字翻版
判据用 `is not None` 而非真值: `Effort.NONE`(明确要求不推理) `None`
(不表态)语义不同前者拿到的是没有推理过程的响应,合并即毒化
"""
key_obj: dict[str, Any] = {
"model": model_fingerprint,
@@ -74,6 +131,8 @@ def build_cache_key(
key_obj["salt"] = salt
if sampling:
key_obj["sampling"] = dict(sampling)
if reasoning_effort is not None:
key_obj["reasoning_effort"] = str(reasoning_effort)
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -114,6 +173,9 @@ class CacheMW:
namespace,
request.cache_salt,
sampling=request.sampling,
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
reasoning_effort=request.reasoning_effort,
)
cached = await self._safe_get(key)
if cached is not None:
@@ -132,6 +194,13 @@ class CacheMW:
data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
structured_data = self._rebuild_structured(fields.get("content", ""), request)
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
fields.update(
cache_hit=True,
latency_ms=0,
+37 -170
View File
@@ -23,7 +23,6 @@ from loguru import logger
from polygateway.errors import (
AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError,
PolyGatewayError,
RequestRejectedError,
@@ -32,10 +31,11 @@ from polygateway.errors import (
SourceNotConfiguredError,
TransientError,
)
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import AdaptivePacer, SourceCooldownMemo
from polygateway.sources import AdaptivePacer
from polygateway.streaming import StreamLivenessTimeout
from polygateway.types import LLMResponse
@@ -50,6 +50,7 @@ if TYPE_CHECKING:
SourceSelector,
Transport,
)
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
BackpressurePolicy,
ChatRequest,
@@ -134,70 +135,6 @@ class StallClock:
self._productive_s += self._now() - started
def _demote_call_failures(
ordered: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float] | None,
) -> list[SourceConfig]:
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
可信替代 = 某未失败候选 health 0.5 × 失败源 health异构池里健康源
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)
无健康视图(round_robin )保持无条件降权(冷启动保护)
"""
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
if not demoted or len(demoted) == len(ordered):
return ordered
if health is None:
return _move_to_tail(ordered, demoted)
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
names = {d.name for d in demoted}
return [s for s in ordered if s.name not in names] + demoted
def _health_gated_reorder(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
if not demoted:
return ordered
names = {d.name for d in demoted}
rest = [s for s in ordered if s.name not in names]
return _insert_after_credible(rest, demoted, health)
def _insert_after_credible(
rest: list[SourceConfig],
demoted: list[SourceConfig],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源"""
bar = 0.5 * max(health(d.name) for d in demoted)
credible = [s for s in rest if health(s.name) >= bar]
junk = [s for s in rest if health(s.name) < bar]
return credible + demoted + junk
def _credible_demotions(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
def _failure_reason(exc: PolyGatewayError) -> str:
"""失败原因归类(CHS governance.py:169 同款)。"""
if isinstance(exc, SourceDeadError):
@@ -241,6 +178,7 @@ class RetryMW:
retry: RetryPolicy,
backpressure: BackpressurePolicy,
quota_full: str = "wait",
circuit_open: str = "fail_fast",
cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None,
emitter: object | None = None,
@@ -248,27 +186,39 @@ class RetryMW:
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
) -> None:
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
self._scope = scope
self._sources = list(sources)
self._selector = selector
# 记账写回与 pacer 结算仍在 `_attempt` 内,故这三者由本类持有并与
# `SourceAdmission` **共享同一实例**(pacer 有在途计数,不可分裂)
self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(gate, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._memo = cooldown_memo or SourceCooldownMemo(now=now)
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
self._health_view = self._outcome_sink.health if self._outcome_sink else None
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
self._emitter = emitter
self._now = now
self._sleep = sleep
self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
memo=cooldown_memo,
pacer=self._pacer,
health_view=self._outcome_sink.health if self._outcome_sink else None,
now=now,
sleep=sleep,
rng=rng,
)
async def __call__(self, request: ChatRequest) -> LLMResponse:
"""执行治理调用;scope 级失败按 §6.1 携结构化字段上抛。"""
@@ -283,16 +233,16 @@ class RetryMW:
clock = StallClock(self._now)
while True:
# 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环
if await self._stalled(clock):
if await self._admission.stalled(clock):
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=self._retry.backoff_base_s,
per_source_reasons=reasons,
)
picked, gate_rejections = await self._pick_runnable(reasons, attempt_fails)
picked, gate_rejections = await self._admission.pick(reasons, attempt_fails)
if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock)
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue
async with clock.attempting() as attempt:
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
@@ -314,87 +264,6 @@ class RetryMW:
if not outcome.immediate:
await self._sleep(self._backoff_delay(max(fails, 1), outcome.exc))
# —— 选源与准入(CHS _pick_runnable 120-167)——
async def _pick_runnable(
self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered:
if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
if not self._pacer.admit(cand.name):
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
reasons.setdefault(cand.name, "adaptive_paced")
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
if entry is None:
await self._settle_and_release(permit, 0)
if entry.allowed:
self._pacer.enter(cand.name)
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit, 0)
return None, gate_rejections
# —— 背压与 stall 判死(CHS governance.py:270-285)——
async def _stalled(self, clock: StallClock) -> bool:
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
无进展**同时**超窗才判死本地 monotonic 与后端时钟刻意不混用
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
本地未超窗就不问后端,省一次 Redis 往返
"""
stall = self._bp.stall_window_s
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
if await self._stalled(clock):
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
# jitter ∈ [0.5p, 1.0p] 防惊群(CHS governance.py:283-285)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
# —— 单次尝试(CHS run 200-268)——
async def _attempt(
@@ -416,6 +285,9 @@ class RetryMW:
stream=request.stream,
overlay=request.overlay,
call_id=call_id,
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
# 自己按选中的源解析,两者在 effective_effort 里汇合)
reasoning_effort=request.reasoning_effort,
)
if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
@@ -460,7 +332,7 @@ class RetryMW:
return _Failed(exc, immediate=dead)
finally:
self._pacer.leave(source.name)
await self._settle_and_release(permit, actual)
await settle_and_release(permit, actual)
async def _on_rejected(
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
@@ -521,20 +393,13 @@ class RetryMW:
cached_prompt_tokens=result.cached_prompt_tokens,
model_reported=result.model_reported,
reasoning_tokens=result.reasoning_tokens,
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
thinking_observation=result.thinking_observation,
# 同理: 实际档由做注入的那一层裁定(`nearest` 映射后与请求档分叉),
# 本层若"顺手"改读 request.reasoning_effort,记的就是从未发出过的档
applied_effort=result.applied_effort,
)
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit(
self,
request: ChatRequest,
@@ -556,6 +421,8 @@ class RetryMW:
latency_ms=int((self._now() - started) * 1000),
response=response,
error=None if error is None else str(error),
# chat 路径是唯一带推理参数的路径,故实发档由这里的响应说了算
reasoning_applies=True,
)
except asyncio.CancelledError:
raise
+235 -9
View File
@@ -23,16 +23,166 @@ from polygateway.errors import (
SourceNotConfiguredError,
)
from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
from polygateway.thinking import effective_effort
from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计
`allow_nan=False` **第二道闸**(主防线是 `types.validate_caller_dimensions`
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
JSON这道闸真正的价值在 **SQLite **PG JSONB 本来就会拒收 `NaN`,
SQLite `meta` TEXT **不做任何 JSON 校验**,没有这道闸就会把 `NaN`
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
`try` 内被求值,异常会被那里的 `except Exception` 接住 warning整行
遥测丢弃即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"
"""
if not meta:
return "{}"
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
def _normalize_observation(raw: object) -> str:
"""三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。
**不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游
(尤其迁移期的测试替身) `LLMResponse(..., thinking_observation="observed")`
完全自然`==` 比较照常成立, `.value` 会当场抛 `AttributeError`, `_record`
`except Exception` 吞成一条泛化 warning 丢的不是这一列,**整行**,
"遥测必录"是铁律
域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`,
落到同一个 `except` 同样丢整行,那只修好了裸 str 一半(口误值对测试替身
一样自然)降级到 `unknown` 是诚实的库确实判不出这个取值的含义,而单独
一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)
"""
try:
return ThinkingObservation(raw).value
except ValueError:
logger.warning(
"thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown"
"(其余列照常落库);调用方应传 ThinkingObservation 成员",
raw,
)
return ThinkingObservation.UNKNOWN.value
def _normalize_effort(raw: object) -> str | None:
"""实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。
**不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse`
是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然,
`.value` 会当场抛 `AttributeError`, `_record` `except Exception` 吞成
一条泛化 warning 丢的不是这一列,**整行**
域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致
(设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名,
归因字段不该有能力废掉一整行遥测降级到 `None` 也是唯一诚实的说法库确实
不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事
注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`:
`'none'` "明确要求不推理",是一次表态
"""
if raw is None:
return None
try:
return Effort(raw).value
except ValueError:
logger.warning(
"推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL"
"(其余列照常落库)",
raw,
)
return None
def _attempt_effort(
*,
request: ChatRequest,
source: SourceConfig,
response: LLMResponse | None,
applies: bool,
) -> str | None:
"""一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。
成功行一律读 `response.applied_effort` **绝不重算**: 源上开了
`EFFORT_FALLBACK=nearest` ,请求 `medium` 而模型只有 low/high/max,实发的是
`low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出
过的分组下而两个值在没开映射的源上恒等,这个错在本地跑不出来
失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射
的源上,成功行是映射后的档失败行是请求档,两种行不是同一把尺子仍然记而不是
留空,是因为档位错误(`resolve_thinking` Phase 2/4/5)根本没发 HTTP 就被拒,
这类行记的正是**被拒绝的那一档**"哪一档配错了"是压测与排障要的信号
回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态
(语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"
"""
if not applies:
return None
if response is not None:
return _normalize_effort(response.applied_effort)
return _normalize_effort(
effective_effort(
request_effort=request.reasoning_effort,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
)
)
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap:
return text
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
def _cap_part(part: Any, cap: int) -> Any:
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
return {**part, "text": _cap_text(part["text"], cap)}
return part
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)
**只产出新对象,严禁就地修改**: `digest_messages` content list 的消息是
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url part 同理
就地改它会一并污染调用方持有的 messages后续重试尝试的请求体与缓存写入的 key,
且全程无任何报错
"""
if cap is None:
return messages
capped: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
capped.append({**msg, "content": _cap_text(content, cap)})
elif isinstance(content, list):
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
else:
capped.append(msg)
return capped
@dataclass(frozen=True)
class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
@@ -51,6 +201,9 @@ class _AttemptUsage:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -68,15 +221,32 @@ class _AttemptUsage:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
)
class TelemetryEmitter:
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 26 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
def __init__(
self,
recorder: TelemetryRecorder,
*,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传
同理,值域校验也放在这一处: 三个 Client `text_cap` 全部汇流到这里,
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
条公共装配路`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)
"""
if text_cap is not None and text_cap <= 0:
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
self._recorder = recorder
self._pricing = pricing
self._text_cap = text_cap
async def emit_attempt(
self,
@@ -87,8 +257,17 @@ class TelemetryEmitter:
latency_ms: int,
response: LLMResponse | None,
error: str | None,
reasoning_applies: bool,
) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
`reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为
`True`,embedding / OCR 路径为 `False`它不能由 emitter 自己推断三条路径
共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` embedding
会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的
**不设默认值**: `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传
当场 TypeError,好过被静默当成"没表态"
"""
usage = _AttemptUsage.of(response)
await self._record(
request=request,
@@ -109,8 +288,14 @@ class TelemetryEmitter:
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
thinking_observation=usage.thinking_observation,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id,
meta=request.meta,
reasoning_effort=_attempt_effort(
request=request, source=source, response=response, applies=reasoning_applies
),
)
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
@@ -136,9 +321,19 @@ class TelemetryEmitter:
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
thinking_observation=response.thinking_observation,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id,
meta=request.meta,
# 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射
# 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的)
reasoning_effort=_normalize_effort(request.reasoning_effort),
)
async def emit_terminal_failure(
@@ -164,8 +359,15 @@ class TelemetryEmitter:
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
thinking_observation=ThinkingObservation.UNKNOWN,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id,
meta=request.meta,
# 可能根本没选出源,故与 sampling 同样只取请求档
reasoning_effort=_normalize_effort(request.reasoning_effort),
)
async def _record(
@@ -190,6 +392,17 @@ class TelemetryEmitter:
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
thinking_observation: ThinkingObservation,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
# issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的
# 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把
# "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制
reasoning_effort: str | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -206,8 +419,12 @@ class TelemetryEmitter:
)
else:
cost = None
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
messages_json = json.dumps(
_cap_messages(digest_messages(request.messages), self._text_cap),
ensure_ascii=False,
)
await self._recorder.record_llm_call(
call_id=call_id,
parent_call_id=request.parent_call_id,
@@ -216,8 +433,8 @@ class TelemetryEmitter:
provider=provider,
source_name=source_name,
messages=messages_json,
response=response_text,
thinking=thinking,
response=_cap_text(response_text, self._text_cap),
thinking=_cap_text(thinking, self._text_cap),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
usage_source=usage_source,
@@ -231,6 +448,15 @@ class TelemetryEmitter:
model_reported=model_reported,
sampling=sampling,
reasoning_tokens=reasoning_tokens,
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
# Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation),
reasoning_effort=reasoning_effort,
)
except asyncio.CancelledError:
raise
+144 -103
View File
@@ -18,13 +18,13 @@ import random
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal
from loguru import logger
from polygateway.client import _aclose_component, _telemetry_status_of
from polygateway.errors import (
AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError,
PolyGatewayError,
RequestRejectedError,
@@ -33,19 +33,21 @@ from polygateway.errors import (
SourceNotConfiguredError,
TransientError,
)
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
ChatRequest,
LLMResponse,
OcrLayoutResult,
OcrTextResult,
TelemetryStatus,
Usage,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
if TYPE_CHECKING:
@@ -107,13 +109,13 @@ class OcrClient:
retry: RetryPolicy,
backpressure: BackpressurePolicy,
quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None,
text_cap: int | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
) -> None:
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
self._scope = scope
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
@@ -124,14 +126,34 @@ class OcrClient:
self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport
self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
self._telemetry = telemetry
self._memo = SourceCooldownMemo(now=now)
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
# 引用才关得到自建的 redis 客户端(设计 §3.4)
self._limiter_backend = limiter
self._breaker_backend = breaker
# 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True
self._owns_transport = False
self._owns_telemetry = False
self._owns_limiter = False
self._owns_breaker = False
self._now = now
self._sleep = sleep
self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
now=now,
sleep=sleep,
rng=rng,
)
self._closed = False
# —— 公共端口(OcrTextPort / OcrLayoutPort)——
@@ -142,9 +164,21 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrTextResult:
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字""""
outcome = await self._call("text", image, session_id, parent_call_id)
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrTextResult(
text=result.text,
@@ -161,9 +195,20 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrLayoutResult:
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素""""
outcome = await self._call("layout", image, session_id, parent_call_id)
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrLayoutResult(
elements=result.elements,
@@ -195,6 +240,8 @@ class OcrClient:
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
@@ -207,13 +254,13 @@ class OcrClient:
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
clock = StallClock(self._now)
while True:
picked, gate_rejections = await self._pick_runnable(reasons)
picked, gate_rejections = await self._admission.pick(reasons, {})
if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock)
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _AttemptOutcome):
return outcome
@@ -228,62 +275,6 @@ class OcrClient:
if not outcome.immediate:
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
async def _pick_runnable(
self, reasons: dict[str, str]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
for cand in self._selector.order(self._sources, stats):
if self._memo.active(cand.name):
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
if entry is None:
await self._settle_and_release(permit)
if entry.allowed:
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit)
return None, gate_rejections
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
stall = self._bp.stall_window_s
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
async def _attempt(
self,
kind: _OcrKind,
@@ -294,9 +285,13 @@ class OcrClient:
reasons: dict[str, str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))
@@ -304,20 +299,47 @@ class OcrClient:
self._feed_outcome(source.name, ok=True)
latency_ms = int((self._now() - started) * 1000)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, result
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _AttemptOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise
except asyncio.CancelledError:
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
)
raise
except (SourceDeadError, TransientError) as exc:
@@ -327,11 +349,20 @@ class OcrClient:
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
self._feed_outcome(source.name, ok=False)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedAttempt(exc, immediate=dead)
finally:
await self._settle_and_release(permit)
await settle_and_release(permit, 0)
async def _invoke(
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str
@@ -368,18 +399,6 @@ class OcrClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
async def _settle_and_release(self, permit: Permit) -> None:
"""settle 恒 0: OCR 无 token 计费(设计 §5 差异①)。"""
try:
try:
await permit.settle(0)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("OCR permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit(
self,
kind: _OcrKind,
@@ -389,16 +408,22 @@ class OcrClient:
started: float,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None,
) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
)
latency_ms = int((self._now() - started) * 1000)
response = None
@@ -430,6 +455,8 @@ class OcrClient:
latency_ms=latency_ms,
response=response,
error=error_text,
# OCR 走 MonkeyOCR 自有端点,没有推理参数可言(理由同 embedding)
reasoning_applies=False,
)
@staticmethod
@@ -440,21 +467,28 @@ class OcrClient:
# —— 生命周期 ——
@property
def telemetry_status(self) -> TelemetryStatus | None:
"""遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。
判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client
`aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)
"""
return _telemetry_status_of(self._telemetry)
async def aclose(self) -> None:
"""幂等释放 transport 连接池与遥测连接(与 EmbeddingClient 对称)。"""
"""幂等释放**自建**资源(与 EmbeddingClient 对称);注入的组件一律不碰"""
if self._closed:
return
self._closed = True
transport_aclose = getattr(self._transport, "aclose", None)
if transport_aclose is not None:
await transport_aclose()
telemetry_aclose = getattr(self._telemetry, "aclose", None)
if telemetry_aclose is not None:
await telemetry_aclose()
else:
telemetry_close = getattr(self._telemetry, "close", None)
if telemetry_close is not None:
telemetry_close()
if self._owns_transport:
await _aclose_component(self._transport)
if self._owns_telemetry:
await _aclose_component(self._telemetry)
if self._owns_limiter:
await _aclose_component(self._limiter_backend)
if self._owns_breaker:
await _aclose_component(self._breaker_backend)
async def __aenter__(self) -> OcrClient:
return self
@@ -479,6 +513,7 @@ class OcrClient:
_build_limiter,
_build_selector,
_build_telemetry,
_mark_owned_components,
)
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
@@ -489,18 +524,24 @@ class OcrClient:
alien = sorted({s.provider for s in sources if s.provider != "monkey"})
if alien:
raise ValueError(f"OCR 装配仅支持 provider=monkey(D9 其余后端预留未实现): 发现 {alien}")
return cls(
client = cls(
scope=gw.scope,
sources=sources,
selector=_build_selector(gw.selector),
limiter=limiter or _build_limiter(gw, sources),
breaker=breaker or _build_breaker(gw),
limiter=limiter if limiter is not None else _build_limiter(gw, sources),
breaker=breaker if breaker is not None else _build_breaker(gw),
transport=MonkeyOcrTransport(),
retry=gw.retry,
backpressure=gw.backpressure,
quota_full=gw.quota_full,
circuit_open=gw.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
# 一半不受控(issue #12)
text_cap=gw.telemetry_text_cap,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
return client
@classmethod
def from_env(
+43 -2
View File
@@ -12,6 +12,7 @@ from typing import Any, Protocol, runtime_checkable
from .types import (
ChatRequest,
Effort,
EmbeddingTransportResult,
LLMResponse,
OcrLayoutResult,
@@ -20,6 +21,7 @@ from .types import (
OcrTextTransportResult,
SourceConfig,
SourceStats,
TelemetryStatus,
TransportResult,
)
@@ -35,7 +37,16 @@ class Middleware(Protocol):
@runtime_checkable
class Transport(Protocol):
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。"""
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)
该参数**不设默认值**, `TelemetryRecorder.record_llm_call` 同一既有约定:
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
"调用方没表态"一次本该报错的漏配就此变成一次悄悄涨价的调用
"""
async def complete(
self,
@@ -45,6 +56,7 @@ class Transport(Protocol):
stream: bool,
overlay: dict[str, Any],
call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult: ...
@@ -243,12 +255,37 @@ class StructuredOutputStrategy(Protocol):
def parse(self, text: str) -> Any: ...
@runtime_checkable
class TelemetryStatusProvider(Protocol):
"""可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。
`TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable`
而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call`
实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断
(设计 §3.3)消费方一律先 isinstance 再取值,取不到就当没有状态可报
"""
@property
def telemetry_status(self) -> TelemetryStatus: ...
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;22 字段冻结(M1 设计 §4.4 + issue #3/#4),唯一调用点是 TelemetryEmitter。
"""遥测后端;26 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning
`tenant_id` `meta` 到达 recorder **已由 emitter 归一化**`tenant_id`
`None` 已转空串,`meta` 已序列化为 JSON 字符串( dict `'{}'`)
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
的裸 `str`(`StrEnum` `str` 子类, asyncpg 的参数编码对子类不保证接受,
遥测写失败又只降级成 warningPG 那一路会静默少一列数据)
`reasoning_effort` 同一先例(issue #20): emitter 已把 `Effort` 取成 `.value`
的裸 `str`,`None` 表示调用方没表态它与 `'none'`(明确要求不推理)不可折叠
recorder 只负责落库,不做任何语义判断, `sampling` 列由
`canonical_sampling_json()` emitter 侧定型是同一先例
"""
async def record_llm_call(
@@ -276,4 +313,8 @@ class TelemetryRecorder(Protocol):
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
tenant_id: str,
meta: str,
thinking_observation: str,
reasoning_effort: str | None,
) -> None: ...
+125 -180
View File
@@ -3,6 +3,9 @@
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
**精确匹配**,未注册即装配期报错注册是纯函数返回新表,不修改共享
状态( asyncio 中立铁律);client `registry` 参数持有自己的表
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么响应算不算推理,
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)
"""
from collections.abc import Mapping
@@ -10,221 +13,163 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class ThinkingWire:
"""一个 provider 表达"开/关/多深"的请求体形态(设计 §3.3)。
三个字段各自的 `None` **语义互不重叠**,混淆任意两个都会退回 issue #5 修掉的
那种静默失效:
============== ==========================================================
``on_base=None`` **形态未知**: 本库不知道该 provider 如何表达"",配了开关
即装配期报错并指路 `register_provider`/`extra_body`
``off=None`` 已知开启形态,**没有关闭形态**( provider 关不掉)
``effort_key`` ``None`` = provider 只有开关没有档位(qwen 系靠
``=None`` ``thinking_budget`` 调深度,不是档位)
============== ==========================================================
`on_base={}` `on_base=None` 同样不可混: 前者是"已知无需注入任何参数即处于
开启档"(经网关的 OpenAI 兼容路径正是如此——档位由 `effort_key` 单独附加),
后者是"不知道怎么表达"
**为什么不是 cherry-studio 那套 wire DSL**: 它要支持 openai-chat /
openai-responses / anthropic-messages / google-generate-content 四种端点协议,
故需要 closed operation 集合与 `budgetWire` 代际变体本库只有一个 OpenAI 兼容
transport,跨协议转换由 new-api 在服务端完成(它自己就有一层 canonical intent),
一个协议一层形态即够(P1 YAGNI)
"""
off: Mapping[str, Any] | None
on_base: Mapping[str, Any] | None
effort_key: str | None
@dataclass(frozen=True)
class ProviderProfile:
"""单个 provider 的能力与差异声明。
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking`
True/False 时并入请求体的参数片段(`enable_thinking` None 时二者都不
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
标签(qwen );supports_native_schema D14 阶梯选择原生 response_format
`thinking` 声明推理参数的**形态**( provider ,数年不变一次);
`strip_think_tags` 声明响应 content 需剥离 ``<think>`` 标签(qwen );
`supports_native_schema` D14 阶梯选择原生 response_format
两档各有三种取值,**语义互不重叠**(issue #5):
========== ==========================================================
``{...}`` 已知的注入片段
``{}`` 已知**无需注入**任何参数即处于该档
``None`` **未知**: 本库不知道该 provider 如何表达这一档
========== ==========================================================
`None` `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
minimax/openai 源静默失效调用方以为关掉了推理,实际什么都没发生
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`
: 本类只声明**形态**(参数长什么样, provider );某个具体模型能否
关闭推理属**能力**( model ), `ThinkingCapability`
: 本类只声明**形态**(参数长什么样, provider );某个具体模型支持哪些
档位属**能力**( model ), `thinking.ThinkingCapability`二者合一在
provider 级表达不了代际差异glm-5.2 能关而 glm-5.3 不能,形态却完全相同
"""
name: str
thinking_on: Mapping[str, Any] | None
thinking_off: Mapping[str, Any] | None
thinking: ThinkingWire
strip_think_tags: bool
supports_native_schema: bool = False
# 经 new-api 中转的口径。四家参考实现(cherry-studio / OpenRouter / LiteLLM /
# new-api 自身)一致的结论: OpenAI 兼容端点上,档位一律走标准的 `reasoning_effort`,
# 跨协议转换(→ Claude 的 thinking、Gemini 的 thinkingConfig)由网关服务端完成。
DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
{
# 注入片段出处: VT llm.py:130-144(开启形态)与 CHS invokers.py:230-238(关闭形态)
"qwen": ProviderProfile(
name="qwen",
thinking_on={"enable_thinking": True},
thinking_off={"enable_thinking": False},
thinking=ThinkingWire(
off={"enable_thinking": False},
on_base={"enable_thinking": True},
# 百炼的深度控制是 `thinking_budget`(token 预算)而非档位;
# 预算型控制本库当前不支持(设计 §11 明确不做)
effort_key=None,
),
strip_think_tags=True,
),
# 官方 thinking_mode 文档: thinking:{type} 是开关,reasoning_effort 是深度,
# V4 一代两者并用(deepseek-v4-* 的档位见能力表)
"deepseek": ProviderProfile(
name="deepseek",
thinking_on={"thinking": {"type": "enabled"}},
thinking_off={"thinking": {"type": "disabled"}},
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
"openai": ProviderProfile(
name="openai",
thinking_on=None,
thinking_off=None,
# issue #20。智谱官方迁移建议原文: 原先用 {"type":"disabled"} 的应改为
# {"type":"enabled"} + reasoning_effort="low"——GLM-5.3 起 thinking.type
# 不再接受 disabled,故"关"这一档由能力表按型号裁定(5.2 能关,5.3 不能)
"zhipu": ProviderProfile(
name="zhipu",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
# kimi-k3 的档位是 low/high/max;thinking.type 为月之暗面的开关形态
"moonshot": ProviderProfile(
name="moonshot",
thinking=ThinkingWire(
off={"thinking": {"type": "disabled"}},
on_base={"thinking": {"type": "enabled"}},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# 2026-08-02 经 new-api 中转实测(findings §2),2026-08-25 复测结论不变。
# enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens 恒等于基线
# 194),reasoning_effort 才是真开关——本段形态据此成立。
# `on_base={"reasoning_effort": "medium"}` 是**权宜之计**(issue #21),不是本段
# 的理想形态: 它退回了"库替下游选一个档"这件本次工作原本要消灭的事。
# 之所以接受: 本次一度改成 `on_base={}`("开"不需要任何参数),该形态依赖
# "模型默认就推理"这个前提,而 T10 真实网关实测推翻了它——MiniMax-M3 不发任何
# 推理参数时 5/5 轮不推理(六个强度值 minimal..max 则全部生效且彼此等价)。
# 于是存量配 ENABLE_THINKING=true 的下游会从"真开推理"静默变成"不推理"。
# 取 medium 是为逐字恢复旧版的 thinking_on,与存量行为一致;M3 六档等价,
# 故选哪档对效果无差别。
# 正解是让 `auto` 受能力表约束(模型不支持"由模型自定"时报错并指路显式档位),
# 属公共行为变更,已记入 gitea issue #21 待下一版处理。
"minimax": ProviderProfile(
name="minimax",
thinking_on={"reasoning_effort": "medium"},
thinking_off={"reasoning_effort": "none"},
thinking=ThinkingWire(
off={"reasoning_effort": "none"},
on_base={"reasoning_effort": "medium"},
effort_key="reasoning_effort",
),
strip_think_tags=False,
),
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底。两档此前标
# None(未知),因为当时无法区分"厂商方言"与"标准字段";`reasoning_effort`
# 是 OpenAI **官方**字段而非方言,发给经网关的兼容端点不会打到不认识它的
# 厂商,故 2026-09-04 起给出标准形态。真正形态未知的 provider 仍走
# register_provider 注册,而不是挂在本段下
"openai": ProviderProfile(
name="openai",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
strip_think_tags=False,
),
# Claude 5 系原生是 thinking.type=adaptive + output_config.effort,Gemini 3 系
# 原生是 thinkingConfig.thinkingLevel;两者的代际方言(Claude ≤4.5 的
# budget_tokens、Gemini 2.x 的 thinkingBudget)由 new-api 的 canonical intent
# 层吸收,本库只发 OpenAI 形态(设计 §3.4)
"anthropic": ProviderProfile(
name="anthropic",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
strip_think_tags=False,
),
"google": ProviderProfile(
name="google",
thinking=ThinkingWire(
off={"reasoning_effort": "none"}, on_base={}, effort_key="reasoning_effort"
),
strip_think_tags=False,
),
}
)
class ThinkingUnsupportedError(ValueError):
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
`ValueError` 的子类而非 `errors.py` 四分类之一它描述的是**配置**
不可满足(装配期就该炸),不是一次调用的运行时失败transport 在请求期
捕获它并翻译为 `RequestRejectedError` 再进四分类单列一个类型是为了让
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
错误误贴成"推理开关无法满足")
"""
@dataclass(frozen=True)
class ThinkingCapability:
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
`ProviderProfile` 的分工: 后者声明**形态**(参数长什么样, provider ,
数年不变一次),本类声明**能力**( model ,同一 provider 每代都变)二者
合一在 provider 级表达不了代际差异实测 MiniMax-M3 可关闭推理,而同厂的
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它
"""
can_disable: bool
evidence: str
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{
"MiniMax-M3": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
),
"MiniMax-M2.7": ThinkingCapability(
can_disable=False,
evidence=(
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
),
),
"MiniMax-M2.5": ThinkingCapability(
can_disable=False,
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
),
"qwen3.7-plus": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
),
"deepseek-v4-pro": ThinkingCapability(
can_disable=True,
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
),
}
)
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
def get_capability(
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
) -> ThinkingCapability | None:
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
`get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)
"""
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
def register_capability(
model: str,
capability: ThinkingCapability,
*,
base: Mapping[str, ThinkingCapability] | None = None,
) -> dict[str, ThinkingCapability]:
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
table = dict(DEFAULT_CAPABILITIES if base is None else base)
table[model] = capability
return table
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
enable_thinking: bool | None,
*,
model: str,
warn_unregistered: bool = True,
) -> Mapping[str, Any]:
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)判定顺序即语义,不可调换形态未知时
无从注入,能力如何无关紧要, Phase 2 必须先于 Phase 4;未登记模型没有
`can_disable` 可读, Phase 3 必须先于 Phase 4
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,
`capability` None(未登记)时无从从别处取得模型名
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
调用再喊只会刷屏判定结果不受此参数影响
"""
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
if enable_thinking is None:
return {}
slot = profile.thinking_on if enable_thinking else profile.thinking_off
direction = "thinking_on" if enable_thinking else "thinking_off"
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
if slot is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r}{direction} 形态未知(模型 {model!r}): "
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
)
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
if capability is None:
if warn_unregistered:
_warn_unregistered(model, profile, slot)
return slot
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
if enable_thinking is False and not capability.can_disable:
raise ThinkingUnsupportedError(
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
f"需要关闭思维链请换用支持关闭的模型"
)
return slot
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
model,
profile.name,
dict(slot),
)
def get_provider(
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
) -> ProviderProfile:
+7 -9
View File
@@ -14,13 +14,11 @@ from __future__ import annotations
import asyncio
import contextlib
import time
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_T = TypeVar("_T")
class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名
"""流活性超时异常。
@@ -38,14 +36,14 @@ class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公
super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)")
async def _anext_within(
it: AsyncIterator[_T],
async def _anext_within[T](
it: AsyncIterator[T],
timeout_s: float,
*,
kind: str,
start: float,
first: bool,
) -> _T:
) -> T:
"""限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。
上游自抛的 TimeoutError cm.expired() 区分,原样上抛不误吞
@@ -59,13 +57,13 @@ async def _anext_within(
raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None
async def stream_with_liveness_timeouts(
source: AsyncIterator[_T],
async def stream_with_liveness_timeouts[T](
source: AsyncIterator[T],
*,
ttft_s: float,
inter_token_s: float,
total_s: float,
) -> AsyncIterator[_T]:
) -> AsyncIterator[T]:
"""逐项产出 source,并施加三层活性超时。
关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield否则总时长
+398 -121
View File
@@ -1,110 +1,165 @@
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 按失败性质三分的降级。
参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc
`taskrun/postgres_store.py`($n 占位`ON CONFLICT DO NOTHING`),但其
"失败冒泡"方向按遥测铁律**有意反转**:
结构性失败 warning 一次后永久降级(所有写入短路);
运行时单条写失败 逐条 warning 丢弃,不降级不重试(连接抖动由
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)
构造不连库(lazy),22 schema SQLite 版同名同序
"失败冒泡"方向按遥测铁律**有意反转**: 遥测失败一律不冒泡,只降级
构造不连库(lazy),24 schema SQLite 版同名同序
**"结构性"的判据是确定写不进去,不是初始化时出过错**(issue #9):
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)"表确定不存在
且建不出来"(后续 INSERT 必然全败)才判死;探测失败、补列失败、取连接
失败一律只 warning,让写入照常尝试或下次调用重试
**降级档位挂在"失败是什么性质",不挂"哪一步失败"**(issue #15)。挂步骤是
issue 的病灶: `min_size=10` "连接耗尽"这种瞬时错误逼到建池那一步,于是它被
一刀切成了永久判死,整进程从此一条遥测都不落,只有重启能恢复判据两句:
1. **致命 = 失败原因完全在进程内部且不可变**DSN 是构造期定死的字符串,是唯一
满足这条的东西;认证失败库不存在表建不出来一律不算DBA 改完就该好
2. **行级 vs 环境级看失败与"这一行的数据"有没有关系**: 只与本行数据有关(换一行
可能成功)= 行级,逐条丢弃;与数据无关每一行都会同样失败 = 环境级,进冷却
`_classify_failure`(全库唯一一处 PG 失败分类) `_handle_failure`(三个降级点
唯一一处处置)
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.telemetry.schema import (
COLUMNS,
PG_BACKFILL,
PG_DDL,
insert_sql,
missing_columns_warning,
)
from polygateway.telemetry.status import TelemetryStatusTracker
if TYPE_CHECKING:
from collections.abc import Callable
import asyncpg
_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
);
"""
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
_BACKFILL = (
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
)
from polygateway.types import TelemetryStatus
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
# 归还连接的独立上限(issue #15)。**不**复用写入预算: 写入预算已经花在
# acquire+execute 上,归还再给它一个同样大的额度,等于允许业务路径上的一次遥测
# 写入吃掉 2 倍预算。归还是本地动作(reset 一次往返),1 秒足够;超时即断开,
# asyncpg 会在下次 acquire 时补一条新连接
_RELEASE_TIMEOUT_S = 1.0
# 关闭池的独立上限(issue #15)。**不**复用写入预算: 关闭跑在收尾路径而非业务
# 路径上,给它一个略宽的固定额度即可,但必须**有界**——asyncpg 的
# `Pool.close()` 会 await 每个 holder 的 `wait_until_released()`,in-flight
# 连接不归还就无限等(`pool.py:939-948, 961-972`,60s 只发一条 warning),
# 其 docstring 自己写着 "advisable to use asyncio.wait_for to set a timeout"
_CLOSE_TIMEOUT_S = 5.0
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
_EXISTING_COLUMNS = (
"SELECT attname FROM pg_attribute "
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
)
_COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
)
# 环境级降级的冷却期(issue #15)。**不暴露配置**: 它的取值只影响"多久重试一次"
# 这个内部节奏,任何取值都不改变对外承诺(降级可见、可自愈、有界成本),给出旋钮
# 只会多一个下游要理解却调不对的东西(设计 §3.5)
_DEGRADE_COOLDOWN_S = 60.0
_INSERT = (
f"INSERT INTO llm_calls ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join(f'${i + 1}' for i in range(len(_COLUMNS)))}) "
"ON CONFLICT (call_id) DO NOTHING"
)
_FATAL = "fatal"
"""配置级致命: 原因完全在进程内部且不可变 → 永久 no-op + 一条 error。"""
_UNAVAILABLE = "unavailable"
"""环境级不可用: 与本行数据无关、每行都会同样失败 → 冷却降级,到期重试一次。"""
_ROW = "row"
"""行级拒绝: 只与本行数据有关 → 逐条 warning 丢弃,不降级。"""
# 环境级的 SQLSTATE 类(前两位): 08 连接、53 资源不足(含 53300 too many
# connections)、57 管理干预、28 认证、3D 库不存在。共同点是"与这一行的数据无关,
# 换一行照样失败",且都能被外部修好
_UNAVAILABLE_SQLSTATE_CLASSES = frozenset({"08", "53", "57", "28", "3D"})
# 类 42 整体归行级(见 `_classify_failure` 的默认档),但这两个码与本行数据无关:
# 42501 = 账号被收走 INSERT 权限,42P01 = 表被迁走/删掉。它们是持续性的环境状态,
# 按类归行级会让每次 LLM 调用都内联付一次往返、刷一条 warning,且永不自愈
_UNAVAILABLE_SQLSTATES = frozenset({"42501", "42P01"})
# **判据的唯一具名例外**(issue #13 的更高优先级承诺): 42703 = 缺列。按判据第 2 句
# 它本该是环境级(缺列时每一行都失败),归行级是因为 manual 档会按现有列裁剪 INSERT
# 继续写——"部分列写进去了 + 缺列逐行 warning 暴露"本身有价值,是下游发现 schema
# 漂移的唯一信号,不该被冷却掉。**新增例外必须同款论证**: 说清它为什么值得违反判据
_ROW_SQLSTATES = frozenset({"42703"})
def _classify_failure(exc: BaseException) -> str:
"""按**失败的性质**分档(全库唯一一处 PG 失败分类);判据见模块 docstring。
分类只认 SQLSTATE 与异常类型,不认"在哪一步失败"后者正是 issue #15 的病灶。
SQLSTATE 而非 asyncpg 异常类白名单: 前者是 PG 标准,不随驱动版本漂移
**认不出来的失败一律给最轻的一档**(`_ROW`): 升档(冷却 60s)要有依据,没依据就
宁可每次调用多付一次内联往返,也不拿 60 秒的遥测去赌一个猜测issue #9 定下的
"探测抖动只跳过本次、下次重试"正是靠这条默认保住的
"""
if isinstance(exc, ValueError | TypeError):
# DSN 不可解析(实测: 端口写成非数字 → 裸 ValueError;scheme 不对 →
# ClientConfigurationError,它本身就是 ValueError 子类)与建池参数非法。
# 这些是构造期就定死的进程内部事实,重试在任何时刻都不可能成功
return _FATAL
sqlstate = getattr(exc, "sqlstate", None)
if isinstance(sqlstate, str):
if sqlstate in _ROW_SQLSTATES:
return _ROW
if sqlstate[:2] in _UNAVAILABLE_SQLSTATE_CLASSES or sqlstate in _UNAVAILABLE_SQLSTATES:
return _UNAVAILABLE
# 其余 PostgresError(22 数据异常、23 约束冲突等)都是这一行的数据问题
return _ROW
# 没有 SQLSTATE = 话还没说到 PG 就断了: OSError(含 ConnectionError 与
# TimeoutError)与 asyncpg 自己的 InterfaceError,都与本行数据无关
return _UNAVAILABLE if isinstance(exc, OSError | _interface_error()) else _ROW
def _interface_error() -> type[BaseException]:
"""asyncpg 的 `InterfaceError` 类型;延迟取用以免模块导入期硬依赖 extra。"""
import asyncpg
return asyncpg.InterfaceError
class PostgresRecorder:
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None) -> None:
def __init__(
self,
dsn: str,
*,
pool: asyncpg.Pool | None = None,
auto_migrate: bool,
pool_max: int,
write_timeout_s: float,
now: Callable[[], float] = time.monotonic,
) -> None:
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
Args:
dsn: asyncpg 连接串(已剥驱动后缀)
pool: 外部注入的池;注入方自己负责关闭
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
则一条 ALTER 都不发`ALTER TABLE ADD COLUMN` ACCESS EXCLUSIVE
,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
awaitkeyword-only **必填**: 缺省规则只写在 config 一处,不与本类
签名漂移(设计 D-c)
pool_max: 自建池的连接数上限(issue #15)。稳态吞吐**按实测折算,不要按
`pool_max / RTT` **(那会乐观一倍): RTT 123ms `pool_max=4`
实测约 15.6 /(50 行并发批 3.2s) `auto_migrate` 同一纪律:
必填,缺省只写在 config 一处
write_timeout_s: 单次写入的硬预算,同时用作 connect acquire 的上限
now: 单调时钟,注入给降级 tracker(测试可推进冷却与节流窗口)
"""
try:
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
except ImportError as exc:
@@ -114,18 +169,41 @@ class PostgresRecorder:
self._dsn = dsn
self._pool: asyncpg.Pool | None = pool
self._external_pool = pool is not None
self._auto_migrate = auto_migrate
self._pool_max = pool_max
self._write_timeout_s = write_timeout_s
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
self._columns: tuple[str, ...] = COLUMNS
self._insert = insert_sql("postgres", COLUMNS)
self._schema_ready = False
self._failed = False # 结构性降级标志: 置位后所有写入短路
self._closed = False # 关了就是关了: 置位后写入短路且**不重建池**
# 降级状态**只此一份**: 是否短路写入、多久重试一次、下游查到什么,
# 全由 tracker 回答。两份状态(曾经的 `_failed` 布尔 + tracker)必然漂移
self._status = TelemetryStatusTracker(backend="postgres", now=now)
self._init_lock = asyncio.Lock()
@property
def telemetry_status(self) -> TelemetryStatus:
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
return self._status.snapshot()
async def _ensure_ready(self) -> asyncpg.Pool | None:
"""lazy 建池+备表;判死只认「确定写不进去」(issue #9),其余失败都留活路。"""
if self._failed:
"""lazy 建池+备表;降级期间**零成本短路**,冷却到期放行一次重新准备。
`should_retry()` 是纯时间比较,不触库: 降级期间的调用因此既不内联吞
connect 超时(`postgres.py` 老注释担心的正是这个),也不需要重启进程
成本变成"每 60s 一次、上界一个写入预算",有界且可解释
`_closed` 在锁内**必须复查**: 等锁期间发生的 `aclose` 否则会被这次
等待"绕过",等到锁时照旧建出一个没人负责关的池(注入档更隐蔽
注入方以为自己管着全部连接,实际早已不是)
"""
if self._closed or not self._status.should_retry():
return None
if self._schema_ready:
return self._pool
async with self._init_lock:
if self._failed:
if self._closed or not self._status.should_retry():
return None
if self._schema_ready:
return self._pool
@@ -135,43 +213,71 @@ class PostgresRecorder:
return await self._prepare_schema(pool)
async def _open_pool(self) -> asyncpg.Pool | None:
"""建池;失败即永久降级(唯一一处「无条件判死」)。"""
"""建池;失败按性质分档处置(见 `_handle_failure`),不再一律判死。
**池的资源占用由本库显式声明**(issue #15): `min_size=0` 的语义是"不预
连接"(asyncpg `pool.py:457` 为 0 时只造 holder 对象,一条连接都不连),
建池因此从"要么拿到 10 条、要么失败"的重资源动作变成零成本不触库的
动作;连接失败自然落到 acquire 那条本来就正确的"丢一行、池自恢复"路径
`max_size` 是库对自己占用的表态继承第三方默认值等于不表态(P4),
那正是共享实例余量紧张时先倒下的原因
"""
if self._pool is not None:
return self._pool
try:
import asyncpg
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
self._pool = await asyncpg.create_pool(
self._dsn,
min_size=0,
max_size=self._pool_max,
timeout=self._write_timeout_s,
command_timeout=self._write_timeout_s,
)
except asyncio.CancelledError:
raise
except Exception as exc:
# 池建不出来 = 确定写不进去;且每次调用重试都要内联吞掉 connect
# 超时,而遥测是业务路径上的 await —— 此处必须永久降级
self._failed = True
logger.warning("Postgres 遥测建池失败,后续记录降级为 no-op: {}", exc)
self._handle_failure(exc, stage="建池")
return None
return self._pool
async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None:
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。
取连接走显式 acquire/release(理由见 `_release`): 准备期同样跑在调用方的
写入预算里,`async with` 那条路的归还会把真实上界撑到 2× 预算
"""
try:
async with pool.acquire() as conn:
writable = await self._prepare_table(conn)
conn = await pool.acquire(timeout=self._write_timeout_s)
try:
columns = await self._prepare_table(conn)
finally:
await self._release(pool, conn)
except asyncio.CancelledError:
raise
except Exception as exc:
# 池已在手,取连接/探测失败多为瞬时抖动: 不判死也不标就绪,
# 只跳过本次记录,下次调用重新准备
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
self._handle_failure(exc, stage="建表探测")
return None
if not writable:
self._failed = True
if columns is None:
# 表确定不存在且建不出来: 与本行数据无关(每行都会同样失败)且能被
# 外部修好(DBA 建了表就该自愈)—— 判据第 2 句下的环境级
self._status.enter_degraded(
"表 llm_calls 不存在且建不出来(记录无处可落)",
fatal=False,
cooldown_s=_DEGRADE_COOLDOWN_S,
)
return None
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
self._columns = columns
self._insert = insert_sql("postgres", columns)
self._schema_ready = True
return pool
async def _prepare_table(self, conn: object) -> bool:
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
返回 None 仅表示表确定不存在且建不出来(调用方据此进环境级冷却降级)
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL schema
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
@@ -184,33 +290,77 @@ class PostgresRecorder:
"""
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
if exists:
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
return True
return await self._resolve_columns(conn) # 旧表可能缺列
try:
await conn.execute(_DDL) # type: ignore[attr-defined]
await conn.execute(PG_DDL) # type: ignore[attr-defined]
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
return False
return True # 新建表列已齐全,无需再走补列
return None
return COLUMNS # 新建表列已齐全,无需再走补列
async def _backfill_columns(self, conn: object) -> None:
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
两条纪律各有实测理由:
不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` ownership
检查早于 `IF NOT EXISTS` 的存在性判断列明明齐全也会失败置位会让
整个 recorder 永久 no-op,补列失败只降级为逐行丢弃的承诺相悖
(SQLite 侧同款守卫,两侧必须对称)
先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
EXCLUSIVE **再判存在性(实测会被一个开着的读事务阻塞)遥测是内联
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
拖垮业务调用探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
**先取 ACCESS EXCLUSIVE **再判存在性(实测会被一个开着的读事务阻塞)遥测是
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
拖垮业务调用探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试
"""
try:
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
for column, statement in _BACKFILL:
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
return COLUMNS
if self._auto_migrate:
await self._backfill_columns(conn, existing)
return COLUMNS
return self._trim_columns(existing)
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
裁剪是关掉 ALTER **前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
都会因未知列被拒 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
INSERT,`insert_sql` ValueError, `_prepare_schema` 里那次调用在 try
**之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡)
回落必须发生在把空列集交给它之前
"""
effective = tuple(column for column in COLUMNS if column in existing)
if not effective:
logger.warning(
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
"现有列: {}",
sorted(existing),
)
return COLUMNS
missing = [column for column in COLUMNS if column not in existing]
if missing:
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
logger.warning(
"{}",
missing_columns_warning("postgres", missing, alien_table="call_id" not in existing),
)
return effective
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不让 recorder 降级**。
不降级的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE`
ownership 检查早于 `IF NOT EXISTS` 的存在性判断列明明齐全也会失败降级会让
整个 recorder 停写(环境级还要停满一个冷却期),补列失败只降级为逐行丢弃的承诺相悖
(SQLite 侧同款守卫,两侧必须对称)补列失败后写入沿用全量列(今天的行为):
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
请显式选 manual
"""
try:
for column, statement in PG_BACKFILL:
if column not in existing:
await conn.execute(statement) # type: ignore[attr-defined]
except asyncio.CancelledError:
@@ -218,24 +368,151 @@ class PostgresRecorder:
except Exception as exc:
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
def _handle_failure(self, exc: BaseException, *, stage: str) -> None:
"""按分档处置一次遥测失败;三个降级点(建池/建表探测/写入)共用这一处。
收敛成一处不只是去重: 三处各写一遍处置,就是三处各自漂移一次判据的机会,
而判据漂移正是 issue #15 的病灶(注释写着"确定写不进去",代码做的是别的事)。
`stage` 只进日志文案,**不参与分档**挂步骤分档正是要被拆掉的错法
"""
verdict = _classify_failure(exc)
if verdict == _FATAL:
# 这里**不再**另发一条 error: 级别由 tracker 按 `fatal` 决定(致命档发
# error——人配错了,本进程内不会自愈)。此处复制一条只会让同一个事实出
# 两条语义重复的日志,并给"级别"这个决策造出第二个源头
self._status.enter_degraded(
f"{stage}失败(配置有误): {exc}", fatal=True, cooldown_s=None
)
elif verdict == _UNAVAILABLE:
# 每一行都会同样失败 → 冷却期内不再内联重试;`_schema_ready` 一并作废,
# 到期那次要重新走准备(表被删/权限被收回都得靠重新准备才能发现已修好)
self._schema_ready = False
self._status.enter_degraded(
f"{stage}失败: {exc}", fatal=False, cooldown_s=_DEGRADE_COOLDOWN_S
)
else:
# 行级不进降级: 换一行可能就成了。逐条出声是 issue #13 的承诺
# (缺列靠这条 warning 暴露 schema 漂移),不因刷屏而节流掉
logger.warning("Postgres 遥测{}失败(丢弃该行,下次调用照常重试): {}", stage, exc)
def _drop_reason(self) -> str:
"""写不进去时说清是**哪一种**写不进去: 关了 / 降级中 / 本次没准备好。
三者的处置完全不同(一个是调用方自己关了却还在写一个等自愈一个下次
就会重试),混成一句话会让对账的人分不清该等还是该修
"""
if self._closed:
return "遥测已关闭"
if self._status.snapshot().degraded:
return "遥测降级中"
return "后端本次未准备好(下次调用重试)"
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
"""写一行遥测;整次写入受硬预算约束,失败逐条丢弃(两级降级之二),绝不冒泡。
**硬预算**(issue #15): 准备 + 取连接 + 执行合计不得超过 `write_timeout_s`。
这把"遥测绝不拖垮业务""靠各处 timeout 参数凑"变成一条可陈述可测试的
保证此前 `pool.acquire()` 无超时(asyncpg 缺省 `timeout=None` = 无限等),
池满时会无限期挂在业务路径上
外部取消照常穿透: `asyncio.timeout` 只把**自己**触发的 cancel 转成
TimeoutError, `CancelledError` 分支必须排在最前且原样 re-raise(铁律)
"""
try:
async with asyncio.timeout(self._write_timeout_s):
await self._write_row(fields)
except asyncio.CancelledError:
raise
except TimeoutError:
logger.warning(
"Postgres 遥测写入超预算 {}s(丢弃该行);后端慢不得拖垮业务调用",
self._write_timeout_s,
)
self._status.record_drop("写入超预算")
except Exception as exc:
# 遥测铁律: 丢一条 < 拖垮调用。这一行无论如何都没了,区别只在于
# **下一行还试不试**——那由失败的性质决定,不由这里决定
self._handle_failure(exc, stage="写入")
self._status.record_drop("写入失败")
async def _write_row(self, fields: dict[str, object]) -> None:
"""预算内的写入本体: 准备 → 取连接 → 执行 → 归还。
取值按 `self._columns`(manual 档可能已被裁剪), `self._insert`
占位符同序两者必须一起改,分开改就是把值写进错位的列
"""
pool = await self._ensure_ready()
if pool is None:
# 降级期间静默 return 就是 issue #15 的破口: 丢行必须计数且节流出声
self._status.record_drop(self._drop_reason())
return
row = tuple(fields[col] for col in _COLUMNS)
row = tuple(fields[col] for col in self._columns)
conn = await pool.acquire(timeout=self._write_timeout_s)
try:
async with pool.acquire() as conn:
await conn.execute(_INSERT, *row)
await conn.execute(self._insert, *row)
finally:
await self._release(pool, conn)
# **恢复的唯一权威证据是一次真正写成功**(未降级时是廉价 no-op)。放在这里
# 而不是准备期: 准备通过不代表写得进去(权限只到 SELECT 时正是如此)
self._status.recover()
async def _release(self, pool: asyncpg.Pool, conn: object) -> None:
"""归还连接;归还路径独立有界,失败即断开(下次 acquire 会补一条新的)。
**不用 `async with pool.acquire()`**(设计 §3.1,已核实): asyncpg
`Pool.release` `await asyncio.shield(ch.release(timeout))`,且那个
timeout 默认复用 acquire 时记录的 `ch._timeout`(`pool.py:886-889,
930-937`)写入预算到期时 cancel execute 处抛出,异常传播中执行
`__aexit__`,此时没有新的 cancel 投递那次 shielded release **正常
等到完成**,业务路径的真实上界因此变成 2 × 预算显式归还才能给它一个
独立的小上限,承诺才精确成立: 主写入尝试 预算,归还路径独立有界
"""
try:
await pool.release(conn, timeout=_RELEASE_TIMEOUT_S)
except asyncio.CancelledError:
raise
except Exception as exc:
# 遥测铁律: 丢一条 < 拖垮调用;仅记 warning(非 pass),池自恢复
logger.warning("Postgres 遥测写入失败(丢弃该行): {}", exc)
# 含 TimeoutError: 归还超时与归还出错的处置相同——断开而不是留一条
# 状态不明的连接在池里(asyncpg 的 reset 失败路径也是这么做的)
logger.warning("Postgres 遥测连接归还失败(强制断开): {}", exc)
self._terminate(conn, label="连接")
@staticmethod
def _terminate(target: object, *, label: str) -> None:
"""强制断开一条连接或整个池;断开本身再失败也只记 warning(遥测绝不冒泡)。
`label` 必填(不给默认值): 两个调用点的诊断价值全在"拆的是哪一层",
默认值只会让其中一处悄悄报错成另一处
"""
try:
target.terminate() # type: ignore[attr-defined]
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Postgres 遥测{}断开失败(交给上层自行回收): {}", label, exc)
async def aclose(self) -> None:
"""幂等关闭自建池;注入的池归注入方管理。"""
"""幂等关闭自建池;**关了就是关了**,此后写入短路且不复活。注入的池归注入方管理。
取消"关完还能自己重建池"的灰色状态(设计 §3.2 4 ): 关闭是所有权的
终结,而恢复是运行时行为(冷却重试),不该是关闭动作的副作用
**关闭动作本身也有界**: `Pool.close()` await 每个 holder
`wait_until_released()`,in-flight 连接不归还就无限等收尾路径上照样是
"遥测拖垮业务"超时即 `terminate()` 强拆: 关闭已在进行,留着一个关不掉的池
既不会自愈也没人再来收外部取消照常穿透(铁律),不当成一次关闭超时
"""
self._closed = True
pool, self._pool = self._pool, None
self._schema_ready = False
if pool is not None and not self._external_pool:
await pool.close()
if pool is None or self._external_pool:
return
try:
await asyncio.wait_for(pool.close(), timeout=_CLOSE_TIMEOUT_S)
except asyncio.CancelledError:
raise
except Exception as exc:
# 含 TimeoutError: 关不掉与关出错的处置相同——强拆
logger.warning("Postgres 遥测池关闭失败(强制断开): {}", exc)
self._terminate(pool, label="")
+315
View File
@@ -0,0 +1,315 @@
"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。
两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块
收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源常量在
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)物理表列 = 26 INSERT 字段 +
`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处
本模块只依赖标准库: `telemetry/` `backends/``transports/``structured/` 同层且
互不依赖(import-linter 契约执法)
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
TABLE = "llm_calls"
# 支持的后端;`insert_sql` / `telemetry_schema_sql` 的取值域
_BACKENDS = ("sqlite", "postgres")
SQLITE_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT
);
"""
PG_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
);
"""
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
SQLITE_BACKFILL = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
("thinking_observation", "TEXT"),
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
# 下游的那份"的列集合与列定义**无法分叉**——本模块存在的全部理由就是不许漂移。
_PG_BACKFILL_DECLS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
# 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
# **库内执行的这份有意不带 `IF NOT EXISTS`**: PG 对它即便列已存在也会先取 ACCESS
# EXCLUSIVE 锁,而遥测是业务路径上的内联 await,故库侧一律"先探测后 ALTER"
# (postgres.py `_backfill_columns` 记有实测)。给人执行的那份见 `telemetry_schema_sql`。
PG_BACKFILL = tuple(
(column, f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}")
for column, decl in _PG_BACKFILL_DECLS
)
COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
"thinking_observation",
"reasoning_effort",
)
_COLUMN_SET = frozenset(COLUMNS)
def insert_sql(backend: str, columns: Sequence[str]) -> str:
"""按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部
字符串拼进 SQL(占位符只保护值,保护不了列名)空集同样来自探测结果,
`INSERT INTO llm_calls () VALUES ()` 两端都语法非法本函数自己拒,不把这个
不变量押在调用方身上sqlite `?`postgres `$n`,
两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)
**PG `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求
分区表的唯一约束必须包含分区键, `created_at` 分区(issue #12 的保留期方案)后
主键变成 `(call_id, created_at)`,带目标的语句匹配不到任何约束,PG 直接拒收
("there is no unique or exclusion constraint matching the ON CONFLICT
specification"),而遥测写失败只逐行 warning——分区部署下会全线静默丢数据。
无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)
Args:
backend: `"sqlite"` `"postgres"`
columns: 要写入的列,顺序即占位符顺序(调用方须按同序取值)
Returns:
完整的 INSERT 语句
Raises:
ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
selected = tuple(columns)
if not selected:
raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL")
unknown = [column for column in selected if column not in _COLUMN_SET]
if unknown:
raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}")
names = ", ".join(selected)
if backend == "sqlite":
placeholders = ", ".join("?" for _ in selected)
return f"INSERT OR IGNORE INTO {TABLE} ({names}) VALUES ({placeholders})"
placeholders = ", ".join(f"${i + 1}" for i in range(len(selected)))
return f"INSERT INTO {TABLE} ({names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
# 缺列告警要打印的补列语句: 库内执行的那份怎么写,打印给人的就怎么写(同源不许漂移)。
# SQLite 侧常量只有列定义,故在此按 TABLE 拼成整条 ALTER;PG 侧常量本就是整条语句。
_ALTER_BY_BACKEND = {
"sqlite": {
column: f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}"
for column, decl in SQLITE_BACKFILL
},
"postgres": dict(PG_BACKFILL),
}
_BACKEND_LABELS = {"sqlite": "SQLite", "postgres": "Postgres"}
# PG 的 ALTER 取 ACCESS EXCLUSIVE 锁,执行时机得由 DBA 自己挑;SQLite 是下游本地文件,无此顾虑
_EXECUTION_NOTES = {"sqlite": "", "postgres": "(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁)"}
def missing_columns_warning(backend: str, missing: Sequence[str], *, alien_table: bool) -> str:
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
看告警的人必须一眼看到丢的是哪几个维度以及怎么补
**住在本模块而不是两个 recorder **: 这条消息拼的是给人执行的 DDL,与库自己
执行的 ALTER 必须同源本模块存在的全部理由就是不许这两者漂移
Args:
backend: `"sqlite"` `"postgres"`
missing: 缺失的列名( `COLUMNS` 保序)
alien_table: 连主键列 `call_id` 都没有该表多半不是本库的 `llm_calls`
Returns:
单条 warning 的完整文本(库只在准备期发一次,不逐行发)
Raises:
ValueError: backend 不在取值域内
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
alters = _ALTER_BY_BACKEND[backend]
selected = tuple(missing)
statements = [f"{alters[column]};" for column in selected if column in alters]
unknown = [column for column in selected if column not in alters]
if unknown:
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
statements.append(
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
f'polygateway.telemetry_schema_sql("{backend}")'
)
label = _BACKEND_LABELS[backend]
head = (
f"{label} 遥测表 {TABLE} 缺主键列 call_id,很可能不是本库的遥测表"
"(库不做二次判定,仍照常尝试写入)"
if alien_table
else f"{label} 遥测表 {TABLE} 缺列,且 auto_migrate=False(库不发任何 DDL)"
)
return (
f"{head};以下维度不会被记录: {', '.join(selected)}"
f"补列请自行执行{_EXECUTION_NOTES[backend]}:\n" + "\n".join(statements)
)
def telemetry_schema_sql(backend: str) -> str:
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。
给不愿意让库在自己的生产表上发 DDL 的下游用: 输出与库运行时执行的 DDL 同源,
照它建完表,库探测到的列就是齐的
**补列语句与库内执行的那份是两套文本,不是一份**: 这份给人执行,必须可重复执行,
PG 变体带 `ADD COLUMN IF NOT EXISTS`(它会先取 ACCESS EXCLUSIVE ,但执行时机
DBA 自己挑,锁风险可控);库内那份不带,靠先探测后 ALTER 规避锁SQLite 没有
`ADD COLUMN IF NOT EXISTS` 语法,只能以注释交代"仅当该列不存在时执行"
Args:
backend: `"sqlite"` `"postgres"`
Returns:
含注释的完整 SQL 脚本
Raises:
ValueError: backend 不在取值域内
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
if backend == "sqlite":
ddl = SQLITE_DDL
notes = (
f"-- 旧表补列(库升级后新增的列)。SQLite 无 ADD COLUMN IF NOT EXISTS 语法,\n"
f"-- 以下每条**仅当该列不存在时执行**(先 PRAGMA table_info({TABLE}) 对照)。"
)
alters = [
f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl};" for column, decl in SQLITE_BACKFILL
]
else:
ddl = PG_DDL
notes = (
"-- 旧表补列(库升级后新增的列)。带 IF NOT EXISTS,整段可重复执行;\n"
"-- 注意它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,请挑低峰执行。"
)
alters = [
f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS {column} {decl};"
for column, decl in _PG_BACKFILL_DECLS
]
header = (
f"-- PolyGateway 遥测表 {TABLE}({backend})\n"
f'-- 由 polygateway.telemetry_schema_sql("{backend}") 生成,与库运行时执行的 DDL 同源。\n'
"-- 新建库执行整段;已有旧表则建表语句自动跳过,只需关注下方补列语句。"
)
return "\n".join([header, "", ddl.strip(), "", notes, *alters, ""])
+104 -78
View File
@@ -19,112 +19,118 @@ import asyncio
import sqlite3
import threading
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
);
"""
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
_BACKFILL_COLUMNS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
from polygateway.telemetry.schema import (
COLUMNS,
SQLITE_BACKFILL,
SQLITE_DDL,
insert_sql,
missing_columns_warning,
)
from polygateway.telemetry.status import TelemetryStatusTracker
_COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
)
_INSERT = (
f"INSERT OR IGNORE INTO llm_calls ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join('?' for _ in _COLUMNS)})"
)
if TYPE_CHECKING:
from polygateway.types import TelemetryStatus
class SQLiteRecorder:
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
def __init__(self, db_path: Path | str) -> None:
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None:
"""建连接与表,并按探测到的列定型本实例的 INSERT 语句。
Args:
db_path: 库文件路径;父目录不存在会自动创建
auto_migrate: True 则给已存在的旧表自动补列(SQLite 侧的缺省档:
下游本地文件, DBA 无迁移工具);False 则一条 ALTER 都不发,
改为按现有列裁剪写入keyword-only **必填**: 缺省规则只写在
config 一处,不与本类签名漂移(设计 D-c)
"""
self._auto_migrate = auto_migrate
# SQLite 侧本次只做可见性: 它的失败模式(目录不可写、文件损坏)在装配期
# 就暴露给下游,不是"跑到一半悄悄断",故降级恒为 fatal,不做冷却重连
self._status = TelemetryStatusTracker(backend="sqlite")
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
self._columns: tuple[str, ...] = COLUMNS
self._insert = insert_sql("sqlite", COLUMNS)
try:
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(_DDL)
conn.execute(SQLITE_DDL)
conn.commit()
self._conn = conn
except (OSError, sqlite3.Error) as exc:
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
self._backfill_columns()
self._status.enter_degraded(f"初始化失败: {exc}", fatal=True, cooldown_s=None)
self._prepare_columns()
def _backfill_columns(self) -> None:
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
@property
def telemetry_status(self) -> TelemetryStatus:
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
return self._status.snapshot()
def _prepare_columns(self) -> None:
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
无守卫的补列会抛 AttributeError 逃出 `__init__`,"静默降级"变成崩溃
补列失败也绝不清空 `self._conn`那会让整个 recorder 永久 no-op,
比逐行丢弃严重得多
无守卫的探测会抛 AttributeError 逃出 `__init__`,"静默降级"变成崩溃
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试
"""
if self._conn is None:
return
try:
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
except sqlite3.Error as exc:
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
return
for column, decl in _BACKFILL_COLUMNS:
if self._auto_migrate:
self._backfill_columns(existing)
return
self._adopt_existing_columns(existing)
def _adopt_existing_columns(self, existing: set[str]) -> None:
"""manual 档: 不发任何 DDL,按现有列裁剪 INSERT,并把缺列一次讲清楚。
裁剪是关掉 ALTER **前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
都会因未知列被拒 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
INSERT,`insert_sql` ValueError,而遥测构造期抛异常就是把"初始化失败静默
降级"的铁律破成崩溃——回落必须发生在把空列集交给它之前。
"""
effective = tuple(column for column in COLUMNS if column in existing)
if not effective:
logger.warning(
"SQLite 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
"现有列: {}",
sorted(existing),
)
return
self._columns = effective
self._insert = insert_sql("sqlite", effective)
missing = [column for column in COLUMNS if column not in existing]
if missing:
# 单参数传入: 补列 SQL 里带 `'{}'` 字面量,拼进 format 模板会被当占位符
logger.warning(
"{}",
missing_columns_warning("sqlite", missing, alien_table="call_id" not in existing),
)
def _backfill_columns(self, existing: set[str]) -> None:
"""auto 档: 给已存在的旧表补新列(issue #3);逐列独立 try,失败只降级为逐行丢弃。
补列失败绝不清空 `self._conn`那会让整个 recorder 永久 no-op,
比逐行丢弃严重得多失败后写入沿用全量列(今天的行为): auto 档承诺的是
"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual
"""
assert self._conn is not None # 内部不变量: 调用方已判空
for column, decl in SQLITE_BACKFILL:
if column in existing:
continue
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
@@ -137,19 +143,39 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
"""写一行遥测;字段集合即 26 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪), `self._insert`
占位符同序两者必须一起改,分开改就是把值写进错位的列
"""
if self._conn is None:
# 改前这里是**裸 return**: 初始化失败后每一行都无声消失,长跑进程里
# 与"遥测正常"外观上完全一致(设计 §1.4 的直接钉子)
self._status.record_drop(self._drop_reason())
return
row = tuple(fields[col] for col in _COLUMNS)
row = tuple(fields[col] for col in self._columns)
try:
await asyncio.to_thread(self._write, row)
except (OSError, sqlite3.Error) as exc:
logger.warning("SQLite 遥测写入失败(降级不冒泡): {}", exc)
# 计数与出声是两件事,少了计数可见性在这条路径上就是假的: 磁盘满 /
# database is locked / 文件被外部改坏时行真的丢了,而 `dropped_rows`
# 恒 0、`degraded` 恒 False,下游读快照对账完全看不见(PG 侧两件都做)
self._status.record_drop("写入失败")
def _drop_reason(self) -> str:
"""连接为 None 时说清是**哪一种**写不进去: 降级中 / 调用方自己关了。
不能写死为"已降级": `close()` 之后 `degraded` False,固定文案会与
下游读到的快照互相矛盾,对账的人分不清该等自愈还是修自己的关闭时序
本侧只有这两态(初始化失败必置降级,此外只剩关闭),故不照抄 PG 的三分
"""
return "遥测已降级" if self._status.snapshot().degraded else "遥测已关闭"
def _write(self, row: tuple) -> None:
assert self._conn is not None # 内部不变量: 调用方已判空
with self._lock:
self._conn.execute(_INSERT, row)
self._conn.execute(self._insert, row)
self._conn.commit()
def close(self) -> None:
+185
View File
@@ -0,0 +1,185 @@
"""遥测降级状态机(issue #15 C 组): 两个 recorder 共用的降级事实源。
存在的理由(设计 §1.4): 遥测降级过去只有**一条** warning,长跑进程里等同于
静默issue 是手工对账(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,
期间 19 次调用一行未落"遥测必录"铁律的实质要求是: 库做不到必录时,必须
**持续可编程地**让下游知道故降级升格为一等对象,两条出路各走一边:
- 人看: 进入/恢复各一条日志,降级期间按行数与时间**双阈值节流复述**(不刷屏,
也不静默);
- 程序看: `snapshot()` 给只读 `TelemetryStatus`,下游可据此对账或告警
本模块**不含任何后端知识**( import asyncpg/sqlite3,也不判失败性质): 失败
分类是各 recorder 的事,tracker 只接受"降级了/恢复了/丢了一行"三个事实
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.types import TelemetryStatus
if TYPE_CHECKING:
from collections.abc import Callable
_DROP_REPEAT_EVERY_ROWS = 100
"""降级期间每丢这么多行复述一次;首行必报。"""
_DROP_REPEAT_EVERY_S = 300.0
"""降级期间距上次复述超过这么久就再报一次——低频调用的进程不能因行数不够而静默。"""
class TelemetryStatusTracker:
"""单个 recorder 的降级状态;非线程安全,由持有它的 recorder 在自己的时序内使用。
时钟经构造参数注入( `GatewayClient(now=...)` 同款): 冷却窗口与节流窗口
都必须能用假时钟测,否则这些行为只能靠真睡验证,而真睡的用例是间歇红的源头
"""
def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None:
"""记下后端名(只用于日志前缀)与时钟;构造后即"未降级"
Args:
backend: 后端名( `postgres`/`sqlite`),仅进日志文案
now: 单调时钟;测试可注入假时钟推进冷却与节流窗口
"""
self._backend = backend
self._now = now
self._degraded_since: float | None = None
self._fatal = False
self._reason: str | None = None
self._retry_at: float | None = None
self._dropped_rows = 0
# 节流窗口: 本段降级里"自上次复述以来"丢了多少行、上次复述在什么时候
self._dropped_since_report = 0
self._last_report_at: float | None = None
self._dropped_at_entry = 0
def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None:
"""进入(或续期)降级;同一原因只讲一次,只刷新冷却窗口。
不重复打日志是刚需而非优化: 冷却到期重试再失败会反复走到这里,每次都讲
就把"降级中"刷成噪音原因变了才算新事实,值得再讲一遍
Args:
reason: 降级原因(已含具体异常文本);同值视为同一次降级的续期
fatal: True = 本进程内不可恢复,此后 `should_retry()` False;
**同时决定日志级别**(见下方发日志处)
cooldown_s: 距下次允许重新准备的秒数;None 表示不自动重试
"""
if self._fatal:
return # 永久档不可被后来的失败覆盖,也不再刷屏
now = self._now()
first_of_this_episode = self._degraded_since is None
announce = first_of_this_episode or reason != self._reason
if first_of_this_episode:
self._degraded_since = now
self._dropped_at_entry = self._dropped_rows
self._dropped_since_report = 0
self._last_report_at = None
self._reason = reason
self._fatal = fatal
self._retry_at = None if fatal or cooldown_s is None else now + cooldown_s
if announce:
# 级别由 `fatal` 决定,且**只在这一处**决定(设计 §3.2): 致命档是"人把
# 配置写错了、本进程内不会自愈",运维必须看见 → error;其余都是外部
# 状态、会自愈 → warning。recorder 侧一度各自再发一条 error,同一个
# 事实因此出两条语义重复的日志,"级别"这个决策也就有了两个源头——两个
# 源头必然漂移,正是本 issue 反复踩的那类错
emit = logger.error if fatal else logger.warning
emit(
"{} 遥测降级(后续记录将被丢弃): {};恢复条件: {}",
self._backend,
reason,
self._recovery_hint(cooldown_s, fatal=fatal),
)
def recover(self) -> None:
"""退出降级并报告本段期间丢了多少行;未降级时是 no-op。
`dropped_rows` **不清零**: 它是进程生命周期内的累计量,下游靠它对账
"""
if self._degraded_since is None:
return
dropped = self._dropped_rows - self._dropped_at_entry
logger.info(
"{} 遥测已恢复(降级持续 {:.1f}s,期间丢弃 {} 行)",
self._backend,
self._now() - self._degraded_since,
dropped,
)
self._degraded_since = None
self._fatal = False
self._reason = None
self._retry_at = None
self._dropped_since_report = 0
self._last_report_at = None
def record_drop(self, reason: str) -> None:
"""记一行被丢弃;按行数与时间双阈值节流复述。
双阈值缺一不可: 只按行数,低频调用的进程会长时间完全静默;只按时间,
高频进程在窗口内丢几万行也只有一条日志,看不出量级
"""
self._dropped_rows += 1
self._dropped_since_report += 1
if not self._should_report():
return
logger.warning(
"{} 遥测丢弃记录(累计 {} 行): {}",
self._backend,
self._dropped_rows,
reason,
)
self._dropped_since_report = 0
self._last_report_at = self._now()
def should_retry(self) -> bool:
"""现在是否允许(重新)准备后端: 纯查询,不触库也不改状态。
未降级 True(本就该正常走准备路径);fatal False;冷却未到 False;
fatal 但没给冷却 False(调用方没安排自动重试,tracker 不替它决定)
"""
if self._fatal:
return False
if self._degraded_since is None:
return True
if self._retry_at is None:
return False
return self._now() >= self._retry_at
def snapshot(self) -> TelemetryStatus:
"""当前状态的只读快照(公共出口 `client.telemetry_status` 的取值点)。"""
now = self._now()
since = self._degraded_since
retry_after_s: float | None = None
if since is not None and self._retry_at is not None:
retry_after_s = max(0.0, self._retry_at - now) # 到期后钳到 0,不给负数
return TelemetryStatus(
degraded=since is not None,
fatal=self._fatal,
reason=self._reason,
degraded_for_s=None if since is None else now - since,
dropped_rows=self._dropped_rows,
retry_after_s=retry_after_s,
)
def _should_report(self) -> bool:
"""本次丢弃是否该出声: 本段降级的第一行、满行数阈值、或超时间阈值。"""
if self._last_report_at is None:
return True
if self._dropped_since_report >= _DROP_REPEAT_EVERY_ROWS:
return True
return self._now() - self._last_report_at >= _DROP_REPEAT_EVERY_S
@staticmethod
def _recovery_hint(cooldown_s: float | None, *, fatal: bool) -> str:
"""把恢复条件写进日志: 运维看到降级后第一个问题就是"它自己会好吗""""
if fatal:
return "需修正配置后重启进程(本进程内不会自愈)"
if cooldown_s is None:
return "下次调用时重试"
return f"{cooldown_s:.0f}s 后自动重试"
+759
View File
@@ -0,0 +1,759 @@
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
`providers.py` 的分工: 那里是**注册表**(provider 长什么样,静态声明的存放
与查找),这里是**决策**(拿声明和响应做判断)P7"决策逻辑与状态存储分离"
本模块**不定义** `ThinkingObservation` 它是 `LLMResponse` 的字段类型,归最
内层 `types.py`;定义在这里会让 `types.py` 反向 import 决策模块(依赖铁律)
"""
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any
from loguru import logger
from polygateway.providers import ProviderProfile, ThinkingWire
from polygateway.types import EFFORT_ORDER, Effort, ThinkingObservation, coerce_effort
def observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation:
"""由多信号裁定推理是否发生;判据按**证据硬度**排序(issue #16/#17)。
推理正文是事实本身,`reasoning_tokens` 是对事实的转述转述缺失时事实仍然
作数2026-08-25 实测: MiniMax 这一路已不再返回
`usage.completion_tokens_details`,而同一次调用里库拿得到 185 字符推理正文;
只认 token 数的判据会把这种情形误判成"没推理"
正文判据取 `strip()` 而非 truthy: 网关响应是外部输入,纯空白串不是证据(P5)
判不出来时返回 `UNKNOWN` 而非 `ABSENT`**不许把"没看见"说成"没发生"**
"""
if thinking.strip():
return ThinkingObservation.OBSERVED
# 负数与 None 同档: `ABSENT` 是"上游明确上报未推理"这个最强的正面结论,坏
# 数据给不出它。当前 transport 已在边界把负数归 None,这里仍要自己闭合——本
# 函数对外承诺"外部输入校验后使用",第二个 transport 直接填该值时,漏判会
# 给出一个方向相反的强结论(P5)
if reasoning_tokens is None or reasoning_tokens < 0:
return ThinkingObservation.UNKNOWN
return ThinkingObservation.OBSERVED if reasoning_tokens > 0 else ThinkingObservation.ABSENT
class ThinkingUnsupportedError(ValueError):
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
`ValueError` 的子类而非 `errors.py` 四分类之一它描述的是**配置**
不可满足(装配期就该炸),不是一次调用的运行时失败transport 在请求期
捕获它并翻译为 `RequestRejectedError` 再进四分类单列一个类型是为了让
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
错误误贴成"推理开关无法满足")
"""
@dataclass(frozen=True)
class ThinkingCapability:
"""某个**具体模型**支持哪些推理档位(设计 §3.2);登记必须附证据与日期。
`ProviderProfile` 的分工: 后者声明**形态**(参数长什么样, provider ,
数年不变一次),本类声明**能力**( model ,同一 provider 每代都变)二者
合一在 provider 级表达不了代际差异实测 MiniMax-M3 可关闭推理,而同厂的
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型
**档位清单而非布尔**(2026-09-04): 旧版是 `can_disable: bool`,表达不了
"关不掉但能调到最低档"这第三种情况 GLM-5.3 系与 Gemini 3 Pro 都是它
现在"能不能关"就是 `Effort.NONE` 在不在清单里,是派生量而非独立字段;三个
派生量一律不存字段,存了必与清单漂移
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它
文档推定与实测必须在 evidence 里说清楚是哪种前者会被 new-api 中转改写
(LiteLLM 里同一个 kimi-k3 `moonshot/` 下三档`perplexity/` 下六档)
"""
supported_efforts: tuple[Effort, ...]
evidence: str
def __post_init__(self) -> None:
"""构造期校验: 空清单与重复档都是登记错误,不能等到请求期才炸。"""
if not self.supported_efforts:
raise ValueError("supported_efforts 至少要有一档: 空清单表达不了任何能力")
if len(set(self.supported_efforts)) != len(self.supported_efforts):
raise ValueError(f"supported_efforts 有重复档: {self.supported_efforts}")
@property
def can_disable(self) -> bool:
"""能否关闭推理 = `none` 在不在清单里(旧 `can_disable` 字段的等价物)。"""
return Effort.NONE in self.supported_efforts
@property
def cheapest_effort(self) -> Effort | None:
"""除 `none` 外最省的一档;关不掉时作为**可执行替代**推荐给调用方。
`AUTO` 参与候选(纯开关型模型只有它可推荐),但因不在 `EFFORT_ORDER` ,
仅当没有任何强度档时才被选中全清单只有 `none` 时返回 None那种模型
没有"最省的开启档"可言
"""
tiers = [e for e in EFFORT_ORDER if e is not Effort.NONE and e in self.supported_efforts]
if tiers:
return tiers[0]
return Effort.AUTO if Effort.AUTO in self.supported_efforts else None
@property
def is_tiered(self) -> bool:
"""是否档位型(除 `none`/`auto` 外仍有强度档)。
用途是**告警文案**: 对纯开关型模型说"可选档位: ..."是错的,它没有档位
"""
return any(e not in (Effort.NONE, Effort.AUTO) for e in self.supported_efforts)
# 证据分三类,evidence 里必须自报家门:
# 实测 = 经 new-api 中转打过真实请求(最硬,不得被文档推定覆盖);
# 文档推定 = 官方文档 / OpenRouter / cherry-studio / LiteLLM 四方交叉;
# 实测未覆盖 = T10 试过但拿不到数据(渠道限额/上游报错/被路由到别的模型),
# 此时**必须写明原因**——"没测到"与"测了没问题"是两回事,T9 之类的下游
# 文档任务不得把前者写成后者。
_MEASURED = "2026-08-02 经 new-api 中转实测"
_T10 = "2026-09-05 经 new-api 中转实测(T10: 短提示词 N=5,声称可关的再加长上下文 N=3 复核)"
_DOC = "2026-09-04 文档推定(官方文档 + OpenRouter + cherry-studio + LiteLLM 四方交叉)"
# T10 的三条判据(报告见 tests/outputs/thinking/,用例见 tests/e2e/test_thinking_live.py):
# ① 关闭方向要求**每轮**未观测到推理,任一轮观测到即证伪;
# ② 短提示词下的"关掉了"必须过长上下文复核——glm-5.3-flash 正是短提示词 5/5
# 未观测到推理、5000 token 长上下文下 2/3 轮露馅(issue #20 的原始现象);
# ③ 上游整片不回传推理信号(kimi/MiniMax/qwen/gpt 这几路的关闭档都是)时,
# "没看见"不算"没发生",另取一个无魔数锚点: 关闭档的 completion_tokens
# 必须严格小于 max 档。
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
{
# —— MiniMax ——
"MiniMax-M3": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_T10}: reasoning_effort=none 关闭成立(短 5/5 + 长上下文 3/3 未观测到推理,"
"completion 恒 3 token,且与 max 档 completion 57-173 锚点可分);六个强度值各 N=5 "
"全部观测到推理,rt 分布完全重叠(minimal 64-124 / low 55-112 / medium 51-104 / "
"high 62-128 / xhigh 58-118 / max 57-170)——**它们是''的六种写法,不是六个深度档**,"
"MiniMax 官方只有开/关两态,配哪一个都一样贵。"
"**`auto` 已从清单移除**: 实测当时 minimax 的「开」在 wire 上是 on_base={}"
"(什么参数都不注入),而 M3 的默认档实测不推理,故 auto 在这条路上表达不了「开」"
"(N=5 全部未观测到推理)。`resolve_thinking` 的 Phase 5 无条件放行 auto,能力表"
"堵不住这条,故 2026-09-05 由 wire 侧兜住: on_base 改回 {'reasoning_effort': 'medium'},"
"存量 ENABLE_THINKING=true 恢复真开推理(权宜之计,正解见 issue #21)。"
"本清单仍不含 auto——它记的是实测结论,不随 wire 的权宜之计变动。"
f"历史: {_MEASURED} N=10 同样成立;enable_thinking / thinking:{{type}} 两种写法对本模型"
"无效,reasoning_effort 才是真开关(findings 2026-08-25 §3.1/§5)。"
"另: 2026-08-25 记录的'MiniMax 这一路已停报 completion_tokens_details'本次**不再成立**"
"——开启档 rt 有值,只有关闭档整片缺 details"
),
),
"MiniMax-M2.7": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 100-161、推理正文 274-482 字符),"
"**关不掉**成立;auto 档 5/5 观测到推理。"
f"历史({_MEASURED}): reasoning_effort=none / thinking:{{disabled}} / thinking:{{adaptive}} "
"各 N=3 全部无效;OpenRouter 登记 mandatory:true,models.dev 登记无控制手段"
),
),
"MiniMax-M2.5": ThinkingCapability(
supported_efforts=(Effort.AUTO,),
evidence=(
f"{_T10}: 请求 none 时 5/5 轮仍观测到推理(rt 104-158),**关不掉**成立;"
"auto 档 5/5 观测到推理(rt 121-245)。"
f"历史({_MEASURED}): 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理"
),
),
# —— qwen(百炼系,开关型) ——
"qwen3.7-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,且与 auto 档 completion 锚点可分);"
"auto 档 5/5 观测到推理。无强度档: 该 provider 的 wire 没有 effort_key,请求 max 当场被库"
"拒(百炼靠 thinking_budget 调深度,预算型控制本库不支持)。"
f"历史({_MEASURED}): enable_thinking=false 关闭(completion 5 token)"
),
),
"qwen3.7-max": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: 同 qwen3.7-plus——none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.6-plus": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen3.5-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
"qwen-plus-latest": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=f"{_T10}: none 短 5/5 + 长 3/3 关闭且锚点可分,auto 档 5/5 观测到推理",
),
# —— deepseek ——
"deepseek-v4-pro": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3 未观测到推理,与 max 档锚点可分);"
"high / max 各 N=5 全部观测到推理(rt 59-73 / 56-69,推理正文 max 档明显更长: "
"135-186 vs 101-124 字符)。默认档按官方 thinking_mode 文档为 high"
),
),
"deepseek-v4-flash": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 16-36 / 12-42)。与 v4-pro 同档,印证官方'与 deepseek-v4-pro 一致'的说法"
),
),
"deepseek-v4-flash-vision-exp": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: none 关闭成立(短 5/5 + 长 3/3,锚点可分);high / max 各 N=5 全部观测到推理"
"(rt 12-17 / 17-34)"
),
),
# —— 智谱 ——
"glm-5.3": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭已实测坐实**——请求 none(注入 thinking:{{type:disabled}})后 "
"5 轮里 4 轮仍观测到推理(rt 7、推理正文 12 字符),只有 1 轮 rt=0;"
"low / high / max 各 N=5 全部观测到推理(rt 55-77 / 47-63 / 48-60,三档分不出深浅)。"
"这一条了结了 issue #20 的核心争议: 当时短提示词下 rt≈1.2 看着像关掉了,实为采样噪声。"
f"文档侧三源一致({_DOC}): 智谱官方 thinking.type 只接受 enabled、迁移建议改用 "
"enabled + reasoning_effort=low,cherry-studio 标 toggle:false,OpenRouter 标 mandatory:true。"
"默认 max。**注意本渠道不校验档位值**: 未登记的 medium 也会被照单接受(实测 rt 62),"
"'网关没报错'在这一路上不构成'该档受支持'的证据"
),
),
"glm-5.3-flash": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **推理不可关闭,且是判据②唯一的现役样本**——请求 none 时短提示词 5/5 轮"
"未观测到推理(看着完全像关掉了),换成 5000 token 长上下文后 3 轮里 2 轮露馅"
"(rt=2、有推理正文)。只跑短提示词的实测会在这个模型上得出相反结论。"
"low / high / max 各 N=5 全部观测到推理(rt 8-60 / 27-91 / 27-70)。默认 max"
),
),
"glm-5.2": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.HIGH, Effort.MAX),
evidence=(
f"{_DOC}: cherry-studio 登记 none/high/max(官方端点默认 max,百炼上默认 high)。"
"**T10 实测未覆盖——该渠道把本型号路由到了别的模型**: 请求 glm-5.2 时 5/5 轮回报 "
"model=glm-5.3(issue #20 记录的 6/6 复现),拿到的行为不属于本型号,故整组数据作废、"
"本行仍是文档推定。**下游风险**: 在本渠道上给 glm-5.2 配 none,库会照本行放行,"
"而真正服务请求的 glm-5.3 关不掉推理——运行期 reconcile 会喊,但那是事后"
),
),
"glm-5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: OpenRouter 登记只支持 reasoning 开关、无 reasoning_effort;cherry-studio 标 toggle:true。"
"**T10 实测未覆盖**: 与 glm-5.2 同因——5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-5.1": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_DOC}: 同 glm-5(OpenRouter reasoning.mandatory=false 且无 supported_efforts)。"
"**T10 实测未覆盖**: 5/5 轮回报 model=glm-5.3,数据不属于本型号"
),
),
"glm-4.6v": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.AUTO),
evidence=(
f"{_T10}: none 关闭成立,且是全表**证据最硬**的一条——短 5/5 + 长 3/3 全部裁定 ABSENT"
"(上游明确上报 reasoning_tokens=0,不是'看不见'),无需锚点旁证;"
"auto 档 5/5 观测到推理(rt 57-153)。model_reported 与请求一致,未被路由"
),
),
# —— 月之暗面 ——
"kimi-k3": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: **可关闭——推翻 T1 的保守登记**。请求 none(注入 thinking:{{type:disabled}})后"
"短 5/5 + 长上下文 3/3 轮无任何推理信号,completion 恒 9 token;同一模型 max 档 "
"completion 明显更大且带推理正文(rt 33-146),锚点可分——故'没看见'这次有正面证据支撑。"
"两源分歧由此了结: OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列。"
"low / high / max 各 N=5 全部观测到推理(rt 21-53 / 38-60 / 33-146)。"
"**model_reported 是 `k3`**(别名,非串台)。官方提示切换档位会使 prefix cache 失效,"
"不宜在会话中途改档"
),
),
"kimi-for-coding": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.HIGH, Effort.MAX),
evidence=(
f"{_T10}: 本型号在 T1 时因'档位清单无直接证据'走 Phase 3 不登记(设计 §8 第三档),"
"现有它自己的实测证据故补登。none: 短 5/5 + 长 3/3 无推理信号、completion 恒 2 token,"
"与开启档锚点可分;low / high / max 各 N=3 全部观测到推理(rt 8-40 / 25-75 / 62-85)。"
"档位词汇沿用月之暗面官方的 low/high/max: 本渠道对 moonshot **不校验档位值**"
"(minimal/medium/xhigh 照样返回 200 并推理),故'没被拒'不构成'受支持',"
"登记一个厂商没声明的档等于替它做承诺"
),
),
# —— OpenAI ——
"gpt-5.4": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
evidence=(
f"{_DOC}: OpenRouter 登记 none/low/medium/high/xhigh,默认 medium;LiteLLM 登记 minimal 不支持。"
"**T10 实测未覆盖**: 该渠道本型号所有账号限流(429 All available accounts are "
"currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与本清单逐字相符"
),
),
"gpt-5.5": ThinkingCapability(
supported_efforts=(Effort.NONE, Effort.LOW, Effort.MEDIUM, Effort.HIGH, Effort.XHIGH),
evidence=(
f"{_T10}: 清单**逐条对上**,是全表验证最完整的一行。none 关闭成立(短 5/5 + 长 3/3,"
"锚点可分);low/medium/high/xhigh 各 N=5 全部观测到推理,且 rt 随档位单调上升"
"(18-21 / 18-22 / 22-34 / 35-65)——本渠道上少见的、档位真的分得开的模型;"
"清单外的 max 与 minimal 各 N=3 全部被上游 400 拒("
"Unsupported value),说明这一路**会校验档位值**,与 zhipu/moonshot 的照单全收相反"
),
),
# —— Anthropic ——
"claude-opus-5": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_DOC}: Anthropic 官方 adaptive thinking + output_config.effort 五档(low/medium/high/"
"xhigh/max),默认 high;OpenRouter 标 mandatory:false 故可关。"
"**T10 实测未覆盖**: 该渠道 claude 全系返回 429「api key 7天限额已用完」,5/5 轮失败。"
"关闭档仍依赖 new-api 把 reasoning_effort=none 转成 thinking 关闭形态,未经验证"
),
),
"claude-sonnet-5": ThinkingCapability(
supported_efforts=(
Effort.NONE,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
),
evidence=(
f"{_DOC}: 同 claude-opus-5(OpenRouter supported_efforts 与默认档一致)。"
"**T10 实测未覆盖**: 同因 429「api key 7天限额已用完」"
),
),
# —— Google ——
"gemini-3.1-pro": ThinkingCapability(
supported_efforts=(Effort.LOW, Effort.MEDIUM, Effort.HIGH),
evidence=(
f"{_DOC}: **推理不可关闭**——Google 官方文档明确 Gemini 3 Pro / 3.1 Pro 无法关闭思考,"
"OpenRouter 亦标 mandatory:true。thinking_level 三档;默认档两源打架"
"(官方文档说 HIGH,OpenRouter 说 medium)。"
"**T10 实测未覆盖**: 该渠道本型号上游报错(bad_response_status_code / openai_error),"
"5/5 轮失败,连默认档基线都没取到,两源分歧仍悬着"
),
),
}
)
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
def get_capability(
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
) -> ThinkingCapability | None:
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
`get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)
"""
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
def register_capability(
model: str,
capability: ThinkingCapability,
*,
base: Mapping[str, ThinkingCapability] | None = None,
) -> dict[str, ThinkingCapability]:
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
table = dict(DEFAULT_CAPABILITIES if base is None else base)
table[model] = capability
return table
@dataclass(frozen=True)
class ThinkingResolution:
"""请求体注入片段 + 本次**实际**生效的档位(设计 §4.1)。
返回 dataclass 而非裸 Mapping,是因为 `nearest` 映射后"请求的档""真正发出
去的档"会分叉(请求 `medium`、模型只有 low/high/max → 实际发 `low`)。遥测必
须记后者: 记请求档会让按档位分组的压测把整行数据挂在一个从未真正发出过的档
,而那种数据错得看不出来
`applied_effort is None` 只出现在 Phase 1(调用方不表态): 库既不注入,也不
去推定模型自己的默认档"没看见"不许说成"发生了"
"""
payload: Mapping[str, Any]
applied_effort: Effort | None
def effective_effort(
*,
request_effort: Effort | None,
source_effort: Effort | None,
enable_thinking: bool | None,
) -> Effort | None:
"""求本次生效的档位: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
**收口成一个纯函数**是本函数存在的全部理由: 装配守卫(`client._guard_thinking`)
与请求热路径(`openai_compat._build_payload`)必须给出**同一个**判定,两处各写
一份就地转换迟早会分叉,而分叉的形态是"装配期放行、运行期报错"最难查的那种
**一律用 `is None` 判有没有表态,不靠真值性**: `Effort.NONE`(要求不推理)
`enable_thinking=False` 都是**表态**而非缺省,`x or y` 式的回落会把后者当成没配
从而跳到下一层那正是本次要消灭的静默失效
语法糖排在最末且 `True AUTO`(开启但不指定强度,不依赖能力表),不是旧版那个
硬编码的 `medium`: 那是库替下游做的档位判断, `medium` GLM/kimi/deepseek
档位表里根本不存在(设计 §4.2 声明过的有意变更)
同源同时配 `enable_thinking` `reasoning_effort` 且语义矛盾,已由
`SourceConfig.__post_init__` 在构造期报错,故这里不再判两个字段说同一件事时,
矛盾是配置错误,不是优先级问题
"""
if request_effort is not None:
return request_effort
if source_effort is not None:
return source_effort
if enable_thinking is None:
return None
return Effort.AUTO if enable_thinking else Effort.NONE
def resolve_thinking(
profile: ProviderProfile,
capability: ThinkingCapability | None,
effort: Effort | str | None,
*,
model: str,
fallback: str = "error",
warn_unregistered: bool = True,
) -> ThinkingResolution:
"""档位 + 两层声明(形态/能力)→ 注入片段;不可满足时 `ThinkingUnsupportedError`。
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
`RequestRejectedError`(四分类之一)**判定顺序即语义,不可调换**:
========== ================================================================
Phase 1 不表态 不注入 `Effort.NONE` 严格区分: 前者是"随模型默认",
后者是"要求不推理"
Phase 2 **该请求档所需的**形态未知 报错(判据见 `_wire_unknown_for`)
无从注入时,模型能力如何都无关紧要,故必须先于 4/5
Phase 3 能力未登记 尽力注入且**不校验档位**没有清单可比对,拿空清单
去拒绝档位就是凭空报错;新模型上线不该被库挡住(设计 §5 R4)
Phase 4 请求 `none` 而模型关不掉 报错并给出 `cheapest_effort`
Phase 5 其余档位打空 报错(或按 `fallback` 映射)
========== ================================================================
**4 必须先于 5**: `none` 只是 5 的一个特例,若让它落进 5 的通用分支,报错就
退化成"不支持 none,可选 low/high/max"丢掉"这个模型根本关不掉"这个关键
信息与可执行替代,下游随后就会去找 `extra_body` 那条绕过的路,而那正是
issue #20 的成因。
**`auto` 不受档位清单约束**: 它表达的是"开启,但不指定强度",在请求体里就是
"不写 `effort_key`",而不是写进 `effort_key` 的某个取值, Phase 5 放行它
反过来判会让存量的 `ENABLE_THINKING=true`(T5 起等价于 `auto`) deepseek-v4
glm-5.3 这类清单里没有 `auto` 的模型上当场报错,而设计 §12 明确承诺存量
配置继续可跑那里唯一允许新报错的是"关闭一个官方不可关的模型"
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,
`capability` None(未登记)时无从从别处取得模型名
`fallback="nearest"` Phase 5 的逃生口,**默认关闭的理由是钱**: 一次静默的
`medium max` GLM-5.3 上是数倍账单(P5"严禁默认值掩盖错误")
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次调用
再喊只会刷屏判定结果不受此参数影响
**裸字符串也收**(设计 §4.4 4 条入口): 本函数在 `__all__` ,下游直调时
传的天然是从 JSON/配置读出来的 `"low"`,而第三参数本次由 `bool` 换成 `Effort`
正是这条入口冒出来的时机签名照实写 `Effort | str`下面每一关的判据都是
`is Effort.X` 的身份比较,`"none" is Effort.NONE` 恒假,不归一的后果不是报错
而是**静默判否**: Phase 2 按开启方向取字段Phase 4 整条被绕过,最后在拼错误
文案时才以 `AttributeError` 现形(一个未文档化也不属四分类的异常)
"""
# Phase 0: 归一 —— 判据全是身份比较,入口不归一则后面每一关都在拿裸串比枚举
if effort is not None:
effort = coerce_effort(effort, origin=f"resolve_thinking(model={model!r})")
# Phase 1: 调用方不表态 —— 与 Effort.NONE 严格区分,用模型自己的默认档
if effort is None:
return ThinkingResolution({}, None)
wire = profile.thinking
# Phase 2: 形态未知 —— 给了档位却不知道怎么发,静默放行就是欺骗调用方
if _wire_unknown_for(wire, effort):
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 的推理形态未知(模型 {model!r},请求档位 "
f"{effort.value!r}): 本库不知道该 provider 如何表达推理。请用 "
f"register_provider 注册形态,或改用 SourceConfig.extra_body 直接下发供应商参数"
)
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
if capability is None:
payload = _inject(profile, effort, model=model)
if warn_unregistered:
_warn_unregistered(model, profile, effort, payload)
return ThinkingResolution(payload, effort)
# Phase 4: 明确关不掉 —— 调用方要的是"不推理"的语义保证,给不了必须说,且必须
# 带一条能立刻照做的替代(见 docstring: 4 先于 5 的理由)
if effort is Effort.NONE and not capability.can_disable:
raise ThinkingUnsupportedError(_cannot_disable(model, capability))
# Phase 5: 档位打空 —— 报错或按 fallback 映射(auto 例外,见 docstring)
applied = _settle_tier(effort, capability, model=model, fallback=fallback)
return ThinkingResolution(_inject(profile, applied, model=model), applied)
def _wire_unknown_for(wire: ThinkingWire, effort: Effort) -> bool:
"""Phase 2 的判据: **按请求档取相关字段**,不是一律看 `on_base`。
旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此只看
`on_base` 会让"关闭形态已知、开启形态未知"的自定义 provider 在请求 `none`
被误拒,且指向它已经做过的 `register_provider`(设计 §2 处置表第 2 )
请求 `none` 时判据是**两者皆 None**,而不是单看 `off`: `ThinkingWire` 的三个
`None` 语义互不重叠`off is None` `on_base` 已知是"该 provider 关不掉"
( `_inject` 说清是缺了哪半边),只有两者皆 None 才是"整个形态未知",此时
指路 `register_provider` 才是对的方向
"""
if effort is not Effort.NONE:
return wire.on_base is None
return wire.off is None and wire.on_base is None
def _settle_tier(
effort: Effort, capability: ThinkingCapability, *, model: str, fallback: str
) -> Effort:
"""Phase 5: 请求档在不在清单里;不在则按 `fallback` 映射或报错,返回**实际**档。
`auto` 直接放行: 它不是写进 `effort_key` 的取值,而是"不写 effort_key"
(理由见 `resolve_thinking` docstring)
"""
if effort is Effort.AUTO or effort in capability.supported_efforts:
return effort
mapped = _nearest_effort(effort, capability) if fallback == "nearest" else None
if mapped is None:
raise ThinkingUnsupportedError(
_tier_unsupported(model, effort, capability, fallback=fallback)
)
logger.warning(
"模型 {} 不支持 reasoning_effort={},按 effort_fallback=nearest 改用最近的 {} 档;"
"本次真正发出去的、以及遥测成功行记的都是后者,但**缓存 key 记的是前者**"
"(CacheMW 在洋葱里比 transport 更外,查缓存时映射尚未发生,拿不到实发档)",
model,
effort.value,
mapped.value,
)
return mapped
def _inject(profile: ProviderProfile, effort: Effort, *, model: str) -> Mapping[str, Any]:
"""按 wire 把档位写成请求体片段;wire 表达不了这一档时报错。
自己重读 `wire` 而不由调用方传 `on_base`: Phase 2 的判据按请求档取相关字段
(`none` `off`,其余档看 `on_base`)之后,"on_base 一定不是 None"这条前提
只对非 `none` 档成立,写进签名反而是句假话
三种 `None` 的语义在此**各自兑现**(ThinkingWire docstring 定义了它们):
`off is None` = provider 关不掉,`effort_key is None` = 它只有开关没有档位
两者都不是"形态未知",故都不指向 `register_provider`指错了排查方向比不指
还糟
"""
wire = profile.thinking
if effort is Effort.NONE:
if wire.off is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 没有关闭形态(模型 {model!r}): "
f"本库知道它如何表达开启,但该 provider 没有可用的关闭参数。"
f"需要不推理请换用支持关闭的 provider 或模型"
)
return wire.off
# 非 none 档的开启形态由 Phase 2 保证已知(内部不变量,不承担生产校验)
assert wire.on_base is not None
if effort is Effort.AUTO:
# auto = 开启但不指定强度: 逐字节等于升级前的 `thinking_on`
return wire.on_base
if wire.effort_key is None:
raise ThinkingUnsupportedError(
f"provider {profile.name!r} 只有推理开关、没有档位键(模型 {model!r}),"
f"表达不了 reasoning_effort={effort.value!r}: 请改用 auto/none 两档,"
f"或用 register_provider 给该 provider 注册 effort_key"
)
return {**wire.on_base, wire.effort_key: effort.value}
def _cannot_disable(model: str, capability: ThinkingCapability) -> str:
"""Phase 4 的文案: 报错必须带一条能立刻照做的替代,否则等于把用户推回起点。
只报"关不掉"而不给出路,下游就会去找 `extra_body` 那条绕过库的路issue #20
的成因正是如此故文案必须含 `cheapest_effort` 的值与 env 键名两样东西
"""
# Phase 4 只在 none 不在清单里时触发,而清单构造期保证非空,故必有一档可推荐
alternative = capability.cheapest_effort
assert alternative is not None
return (
f"模型 {model!r} 无法关闭推理,reasoning_effort='none' 无法满足: "
f"{capability.evidence}。最省的开启档是 {alternative.value!r}——请配 "
f"{{SCOPE}}__{{PROVIDER}}__{{N}}__REASONING_EFFORT={alternative.value},"
f"或调用时传 reasoning_effort=Effort.{alternative.name};"
f"真正需要不推理请换用支持关闭的模型"
)
def _tier_unsupported(
model: str, effort: Effort, capability: ThinkingCapability, *, fallback: str
) -> str:
"""Phase 5 的文案: 按 `is_tiered` 分叉,纯开关型模型不能被告知"可选档位"
它没有档位对它说"可选档位: none, auto"是把开关说成了强度轴,下游照着找
档位只会一无所获(设计 §3.2 第三个派生量的用途就是这一句话该怎么说)
"""
listed = ", ".join(e.value for e in _ordered(capability.supported_efforts))
head = f"模型 {model!r} 不支持 reasoning_effort={effort.value!r}: {capability.evidence}"
body = (
f"该模型的可选档位: {listed}"
if capability.is_tiered
else f"该模型只有开关、没有强度档位,可用: {listed}"
)
# 已经开着 nearest 还走到这里,说明映射本身无解,再劝一遍是废话
hint = "" if fallback == "nearest" else ";若希望自动落到最近的档,请配 EFFORT_FALLBACK=nearest"
return f"{head}{body}{hint}"
def _ordered(efforts: tuple[Effort, ...]) -> list[Effort]:
"""按由弱到强列出档位;`auto` 不在强弱轴上,排在末尾。"""
ordered = [e for e in EFFORT_ORDER if e in efforts]
if Effort.AUTO in efforts:
ordered.append(Effort.AUTO)
return ordered
def _nearest_effort(requested: Effort, capability: ThinkingCapability) -> Effort | None:
"""取距 `requested` 位序最近的**开启档**;等距取弱侧,无开启档时返回 None。
候选**剔除 `none`**: "想得浅一点"映射成"别想了"是方向反转而非省钱,正是
issue #20 那种静默失效的翻版。`none` 的领域归 Phase 4,它在那里已经被处理过,
走不到这里(能关就不会打空,不能关就已经报错)
`auto` 不在强弱轴上(`EFFORT_ORDER` 不含它),故不参与距离计算,只在一个强度
档都没有时兜底它恰好是纯开关型模型唯一能表达""的档
**等距取弱**的理由是钱: 一次静默的 `medium max` GLM-5.3 上是数倍账单,
库不替下游涨价
"""
candidates = [
e for e in EFFORT_ORDER if e is not Effort.NONE and e in capability.supported_efforts
]
if not candidates:
return Effort.AUTO if Effort.AUTO in capability.supported_efforts else None
target = EFFORT_ORDER.index(requested)
# 排序键第二位是位序本身: 距离相同时位序小的(更省的)胜出
return min(
candidates, key=lambda e: (abs(EFFORT_ORDER.index(e) - target), EFFORT_ORDER.index(e))
)
def _warn_unregistered(
model: str, profile: ProviderProfile, effort: Effort, payload: Mapping[str, Any]
) -> None:
logger.warning(
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {}(请求档位 {});"
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
model,
profile.name,
dict(payload),
effort.value,
)
def reconcile_thinking(
*,
effort: Effort | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 evidence 曾停在 8-02 整整 23 ),而过期的
表现是静默错觉本函数把它变成可报警事件,代价是一次枚举比较
**判据是档位而非布尔**(2026-09-05,设计 §4.3): `Effort.NONE` "要求关闭"
一支,其余任何档走"要求开启"一支,`None`(不表态)仍沉默判据必须写成
`is Effort.NONE` **身份比较**它的取值是非空串 `"none"`,任何靠真值性
的写法(`if not effort`)都恒为假,会把每个强度档送进关闭分支,告警方向整个
颠倒传入的应是**实际发出去**的那一档(`nearest` 映射后与请求档分叉),
否则文案会说一个从未发出过的档
**不新增**档位高低 vs `reasoning_tokens` 多少的对账(设计 §4.3/§11 1
): 二者没有可判定的函数关系(实测同一档 rt 8~56 之间跳),拿它报警必然
是噪声,而噪声等于没有告警该问题归 §11 的压测,不进库
**只判定不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
而不必去解析日志格式;节流也才能留在握有实例状态的 transport
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
warning(P5 "报错而非放行"只约束限流/熔断)矛盾结果已随 `LLMResponse`
与遥测落地,处置权归下游
"""
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
if effort is None:
return None
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
if effort is Effort.NONE:
if observation is not ThinkingObservation.OBSERVED:
return None
return _off_but_observed(model, capability)
# Phase 3: 要求开启(含 auto 与各强度档)—— ABSENT 是正面证伪,UNKNOWN 是
# "看不见",两者文案不可混。文案写出**是哪一档**: transport 的节流键正按档
# 分离,文案不分档的话,两条告警长得一模一样,看的人分不出是哪一档出的问题
if observation is ThinkingObservation.ABSENT:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 未生效: 已注入开启参数,"
f"上游却明确上报本次未推理(reasoning_tokens=0)"
)
if observation is ThinkingObservation.UNKNOWN:
return (
f"模型 {model!r} 的 reasoning_effort={effort.value!r} 无法确认是否生效: "
f"已注入开启参数,但本次响应观测不到任何推理信号(推理正文与 usage 明细双缺)。"
f"若走的是非流式路径,推理内容可能已计费却不回传"
)
return None
def _off_but_observed(model: str, capability: ThinkingCapability | None) -> str:
"""关闭请求未被满足的两种说法;登记与否决定该说哪一句。
两者必须分开: `resolve_thinking` 对未登记模型的告警是**事前猜测**,这里是
**事后实证**对未登记模型说"能力表声称可关闭"是错的它根本没登记
"""
if capability is None:
return (
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
f"请实测后用 register_capability 登记其真实能力"
)
return (
f"模型 {model!r} 的 reasoning_effort='none' 未被满足: 实测观测到推理发生,"
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
f"能力表可能已过期——请复测后用 register_capability 更新登记"
)
@@ -0,0 +1,63 @@
"""HTTP 错误响应体的取用与摘要(issue #10 设计 §3.2)。
两个 transport 各有自己的状态码分类逻辑(OCR 有意不做 429 细分),**摘要口径
必须是同一份**issue #10 的教训正是"只有一个分支用了响应体",一处例外就是
下一次事后查不到原因故本模块是全库唯一的摘要实现,不得在别处复制
"""
from __future__ import annotations
import httpx
_ERROR_BODY_CAP = 2048
"""摘要总长上限(**字符**,含省略标记在内)。
取值对齐 Kubernetes client-go `rest/request.go` `maxUnstructuredResponseTextBytes
= 2048`它是唯一与本设计同场景( HTTP 错误体做诊断)的成熟先例按字符而非
字节切,多字节字符不会被切成半个;`error` 列是 TEXT,无定长约束,不需要字节口径
"""
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
def summarize_body(text: str) -> str:
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。
**折叠空白**不是洁癖: 错误体常是缩进 JSON,原样拼进 message 会把一行日志
炸成多行把遥测列变得不可读
**保头保尾**而非头部硬切: 截断的对象是结构化 JSON,信息分布头重尾也重
人话(`message`)在前,机器可判的 `type`/`code`/`param`/`request_id` 在后
k8s/Sentry 用头部硬切是因为它们截的是任意文本;本函数截的是错误 JSON,
头部硬切正好切掉向网关方追查时唯一有用的那部分策略取自标准库 `reprlib`
"给人读的长字符串"的处置
**标记记下省略字数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多
"""
collapsed = " ".join(text.split())
if len(collapsed) <= _ERROR_BODY_CAP:
return collapsed
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
def compose_message(message: str, summary: str) -> str:
"""摘要非空才拼后缀,避免留下悬空的分隔符。
分隔符取 ` | ` 而非既有的 `: `,"库说的话""网关说的话"一眼可分
"""
return f"{message} | {summary}" if summary else message
def response_body(response: httpx.Response) -> str:
"""取**已缓冲**的响应文本;未读缓冲一律降级空串。
绝不在此触发网络读: 那会在错误路径上凭空插入一次可能挂住的 IO降级方向
与缓存/遥测同档(库铁律)诊断信息缺失不得把一次本可正确分类的失败变成
不可分类的崩溃,那正是 `ResponseNotRead` 泄漏出四分类之外的后果
"""
try:
return response.text
except httpx.ResponseNotRead:
return ""
+13 -1
View File
@@ -24,6 +24,11 @@ from polygateway.errors import (
SourceDeadError,
TransientError,
)
from polygateway.transports._http_errors import (
compose_message,
response_body,
summarize_body,
)
from polygateway.types import (
OcrLayoutElement,
OcrLayoutTransportResult,
@@ -74,13 +79,20 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
def _classify_status(
exc: httpx.HTTPStatusError, source_name: str, operation: str
) -> TransientError | SourceDeadError | RequestRejectedError:
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份
"只有一个分支用了响应体"正是 issue #10 的成因。
"""
status = exc.response.status_code
summary = summarize_body(response_body(exc.response))
ctx: dict[str, Any] = {
"source_name": source_name,
"status_code": status,
"operation": operation,
"body_text": summary,
}
message = f"{source_name} OCR {operation} HTTP {status}"
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
if status >= 500 or status == 429:
return TransientError(message, **ctx)
if status in (401, 403):
+158 -40
View File
@@ -11,26 +11,37 @@ from __future__ import annotations
import json
import re
import time
from dataclasses import replace
from typing import TYPE_CHECKING, Any
import httpx
from loguru import logger
from polygateway.errors import (
PolyGatewayError,
RequestRejectedError,
ResultInvalidError,
SourceDeadError,
TransientError,
)
from polygateway.providers import (
ProviderProfile,
from polygateway.providers import ProviderProfile, get_provider
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.thinking import (
ThinkingCapability,
ThinkingUnsupportedError,
effective_effort,
get_capability,
get_provider,
observe_thinking,
reconcile_thinking,
resolve_thinking,
)
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import (
Effort,
EmbeddingTransportResult,
SourceConfig,
TransportResult,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Callable, Mapping
@@ -107,40 +118,59 @@ def _parse_retry_after(raw: str | None) -> float | None:
return seconds if seconds > 0 else None
def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception:
def _translate_429(
source: SourceConfig, body_text: str, headers: Mapping[str, str], ctx: dict[str, Any]
) -> Exception:
"""429 细分。`body_text` 必须是**未截断的原文**——`ctx["body_text"]` 是摘要,
头尾保留会破坏 JSON 结构,拿它解析会让超长 body 的配额耗尽退化成普通限速
(该源不再 force_open),把一个诊断改进变成治理 bug(issue #10 实现红线)。
"""
try:
err_type = json.loads(body_text).get("error", {}).get("type", "")
except (json.JSONDecodeError, AttributeError):
err_type = ""
summary = ctx["body_text"]
if err_type == "insufficient_quota":
return SourceDeadError(
f"{source.name} 配额耗尽(insufficient_quota)",
source_name=source.name,
status_code=429,
operation="chat",
compose_message(f"{source.name} 配额耗尽(insufficient_quota)", summary), **ctx
)
return TransientError(
f"{source.name} 限速: 429",
compose_message(f"{source.name} 限速: 429", summary),
retry_after_s=_parse_retry_after(headers.get("retry-after")),
source_name=source.name,
status_code=429,
operation="chat",
**ctx,
)
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
"""状态码 → (错误类, message 标签);映射与 ARCH §6.2 逐条相同,本次零变更。"""
if status in (401, 403):
return SourceDeadError, "凭据失效/欠费"
if status == 400:
return RequestRejectedError, "请求被拒"
if status >= 500:
return TransientError, "瞬时错误"
return RequestRejectedError, "客户端错误"
def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
) -> Exception:
ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"}
if status in (401, 403):
return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx)
if status == 400:
return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx)
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
摘要只算一次,message `body_text` 共用同一份串: 两份不同长度会让"遥测里
看到的""下游 catch 到的"对不上,排查时反而多一层困惑。
"""
summary = summarize_body(body_text)
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"body_text": summary,
}
if status == 429:
return _translate_429(source, body_text, headers)
if status >= 500:
return TransientError(f"{source.name} 瞬时错误: {status}", **ctx)
return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)
return _translate_429(source, body_text, headers, ctx)
cls, label = _classify(status)
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
def _strip_think(content: str) -> tuple[str, str]:
@@ -299,6 +329,12 @@ class OpenAICompatTransport:
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
self._warned_models: set[str] = set()
# 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记
# 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {}
@@ -317,7 +353,14 @@ class OpenAICompatTransport:
profile: ProviderProfile,
stream: bool,
overlay: dict[str, Any],
) -> dict[str, Any]:
reasoning_effort: Effort | None,
) -> tuple[dict[str, Any], Effort | None]:
"""组装请求体,并交回本次**实际**发出去的档位(`None` = 未表态,不注入)。
返回二元组而非只返回 payload: 实际档在 `nearest` 映射后与请求档分叉,
除本函数外没有第二处知道映射结果不交出去,遥测就只能事后再算一遍,
算出来必是请求档
"""
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
if stream:
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
@@ -326,20 +369,28 @@ class OpenAICompatTransport:
capability = get_capability(source.model, table=self._capabilities)
first_time = source.model not in self._warned_models
self._warned_models.add(source.model)
payload.update(
resolve_thinking(
profile,
capability,
source.enable_thinking,
model=source.model,
warn_unregistered=first_time,
)
# 三层优先级在此汇合: 请求级 > 源级 > enable_thinking 语法糖(设计 §4.2)。
# 判定与装配守卫共用同一个纯函数,两处分叉就会变成"装配期放行、运行期报错"
resolution = resolve_thinking(
profile,
capability,
effective_effort(
request_effort=reasoning_effort,
source_effort=source.reasoning_effort,
enable_thinking=source.enable_thinking,
),
model=source.model,
# 源级 `EFFORT_FALLBACK` 必须真的走到这里: 硬编码 "error" 会让人类明确
# 要求实现的 `nearest` 在零告警下变成死代码(2026-09-05 独立验证查出)
fallback=source.effort_fallback,
warn_unregistered=first_time,
)
payload.update(resolution.payload)
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
# overlay(含结构化注入)在后覆盖之。两行不可调换
payload.update(source.extra_body)
payload.update(overlay)
return payload
return payload, resolution.applied_effort
async def complete(
self,
@@ -349,12 +400,22 @@ class OpenAICompatTransport:
stream: bool,
overlay: dict[str, Any],
call_id: str,
reasoning_effort: Effort | None,
) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
`reasoning_effort` **请求级**档位(`None` = 不表态);它与源级配置的优先级
`_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到
"""
profile = get_provider(source.provider, registry=self._registry)
try:
payload = self._build_payload(
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
payload, applied_effort = self._build_payload(
messages=messages,
source=source,
profile=profile,
stream=stream,
overlay=overlay,
reasoning_effort=reasoning_effort,
)
except ThinkingUnsupportedError as exc:
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
@@ -369,8 +430,9 @@ class OpenAICompatTransport:
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
try:
if stream:
return await self._complete_stream(client, url, payload, source, profile)
return await self._complete_once(client, url, payload, source, profile)
result = await self._complete_stream(client, url, payload, source, profile)
else:
result = await self._complete_once(client, url, payload, source, profile)
except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
except httpx.TimeoutException as exc:
@@ -378,6 +440,50 @@ class OpenAICompatTransport:
except httpx.TransportError as exc:
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
# 实际发出去的档只有 `_build_payload` 知道,而组装 TransportResult 的两条
# 路径都在更深一层。在此唯一汇合点补齐,好过给两条路径各加一个参数——那正是
# 遥测那边被明令禁止的"复制参数列表"形态,两条路径迟早只改一条
result = replace(result, applied_effort=applied_effort)
# 此处是唯一同时握有请求档位与响应结果的地方,对账只能落在这里
self._warn_on_thinking_mismatch(source, result)
return result
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
"""声明与观测矛盾即 warning;按 (source, model, 实际档位) 节流,同组合只喊一次。
三段缺一不可**档位**: 同一模型的 low max 是两个独立的矛盾,共用一个
键会让第二个永久静音(旧版拿 `enable_thinking` 当第三段,而档位根本不经过
那个字段,于是同一模型的所有档共用一个键)**源名**: 多源多账号是本库的
核心场景,同一 model N 个源是常态,而每个源背后是独立的账号/网关,一个
源的行为不代表另一个漏掉源名,5 个源里第一个出问题的喊完一次,其余四个
永久静音逐次调用刷屏会把告警变成噪声,噪声等于没有告警
档位取 `result.applied_effort`(真正发出去的那一档)而非请求档: `nearest`
映射后二者分叉,而对账问的是"我发出去的要求有没有被满足"拿一个从未发出
过的档去对账,文案与键都指向了一次不存在的请求被映射到同一档的两个请求
因此共用一个键,这正是它们该有的关系(同一条实际要求,同一个矛盾)
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费
"""
key = (source.name, source.model, result.applied_effort)
if key in self._warned_mismatches:
return
message = reconcile_thinking(
effort=result.applied_effort,
observation=result.thinking_observation,
capability=get_capability(source.model, table=self._capabilities),
model=source.model,
)
if message is None:
return
self._warned_mismatches.add(key)
# 源名拼在调用点而不是加进 `reconcile_thinking` 的签名: 那是纯判定函数,
# 输入只该含判定依据(声明/观测/能力/模型),源名是**定位信息**,进不了判据。
# 单参数传入 loguru: 文案里带 `thinking:{type:disabled}` 这类字面花括号
# (能力表 evidence),将来有人给这行加个格式化参数就会炸在成功调用的返回
# 路径上(与 telemetry/sqlite.py 的缺列告警同一先例)
logger.warning("{} —— {}", source.name, message)
async def embed(
self, *, texts: list[str], source: SourceConfig, call_id: str
@@ -439,6 +545,7 @@ class OpenAICompatTransport:
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -450,7 +557,12 @@ class OpenAICompatTransport:
raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
reasoning_tokens=reasoning_tokens,
# 两条组装路径必须同口径裁定: 只在一条路径上给结论,下游就得靠
# "这次是不是流式"去猜可观测性,那正是 issue #16/#17 的根因形态
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
def _check_done(
@@ -524,6 +636,7 @@ class OpenAICompatTransport:
)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult(
content=content,
thinking=thinking,
@@ -535,7 +648,12 @@ class OpenAICompatTransport:
raw={"usage": body.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
model_reported=_coerce_model_reported(body.get("model")),
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
reasoning_tokens=reasoning_tokens,
# 本路径的裁定多半落 UNKNOWN(M3 实测: 推理已计费却正文与 details 双
# 缺)。如实标记"观测不到",好过让下游误读成"没推理"
thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens
),
)
async def aclose(self) -> None:
+360 -1
View File
@@ -6,8 +6,11 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
import dataclasses
import json
import math
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType
from typing import Any
@@ -15,6 +18,9 @@ from loguru import logger
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
_EFFORT_FALLBACK_DOMAIN = frozenset({"error", "nearest"})
"""`SourceConfig.effort_fallback` 的值域: 请求档打空时报错,还是映射到最近的档。"""
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
{
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
@@ -31,6 +37,18 @@ USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
_EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
_TENANT_ID_MAX_LEN = 128
_META_MAX_KEYS = 16
_META_VALUE_MAX_LEN = 256
_META_RESERVED_PREFIX = "pg_"
_META_KEY_RE = re.compile(r"[a-z0-9_.]{1,64}")
"""调用方维度的形态上限(issue #11 §4.2)。
数值取自同类系统的量级(Loki labels 15 / Salesforce 自定义索引 25 /
Sentry tag 200 字符),非本项目实测;键字符集照搬 OTel semconv
`pg_` 前缀留给库将来的内建维度同类做法见 LangSmith `ls_`
Traceloop `traceloop.`;本版库自身不写入任何该前缀的键"""
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
@@ -59,6 +77,87 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict
return dict(overlay)
def validate_caller_dimensions(
tenant_id: str | None,
meta: Mapping[str, Any] | None,
*,
origin: str,
) -> tuple[str | None, dict[str, Any]]:
"""校验调用方自定义维度并返回浅拷贝;origin 用于把错误指回调用点(issue #11 §4.2)。
一切超限**报错而非静默丢弃**(P5): 同类系统里 Langfuse 对超长 value 直接
丢掉,那会让调用方以为记上了而实际没有报错点必须在进洋葱之前洋葱内
的失败都被遥测层降级成 warning,校验放那里等于没有校验
"""
_validate_tenant_id(tenant_id, origin)
if meta is None:
return tenant_id, {}
# 键形态须先于值校验: 非 str 键若拖到值校验之后,会以更晦涩的形态报出来
_validate_meta_keys(meta, origin)
_validate_meta_values(meta, origin)
return tenant_id, dict(meta)
def _validate_tenant_id(tenant_id: str | None, origin: str) -> None:
"""租户标识形态;首尾空白**拒绝而非 strip**(见函数体注释)。"""
if tenant_id is None:
return
if not isinstance(tenant_id, str):
raise ValueError(f"{origin} 的 tenant_id 必须是 str: {tenant_id!r}")
# 悄悄 strip 会让 " t1" 变成 "t1": 二者在 RLS policy 的等值比较下是两个
# 不同租户,替调用方改写值等于把它的行藏进另一个租户,且不报错
if tenant_id != tenant_id.strip():
raise ValueError(
f"{origin} 的 tenant_id 不得含首尾空白: {tenant_id!r}"
"(RLS 等值比较下它与去空白版本是两个租户)"
)
if not tenant_id:
raise ValueError(f"{origin} 的 tenant_id 不得为空串(空串是未归属行的哨兵值)")
if len(tenant_id) > _TENANT_ID_MAX_LEN:
raise ValueError(f"{origin} 的 tenant_id 超长(上限 {_TENANT_ID_MAX_LEN}): {len(tenant_id)}")
def _validate_meta_keys(meta: Mapping[str, Any], origin: str) -> None:
"""键形态与数量;键集合被假定为低基数且稳定,故收紧到 OTel semconv 字符集。"""
# 数量闸先于逐键校验: 这道闸要防的正是"整个请求体被塞进 meta"的形态,
# 那时逐键正则会先跑上万次才报出真正的原因,拖慢的恰是出错路径
if len(meta) > _META_MAX_KEYS:
raise ValueError(f"{origin} 的 meta 键数超限(上限 {_META_MAX_KEYS}): {len(meta)}")
for key in meta:
if not isinstance(key, str):
raise ValueError(f"{origin} 的 meta 键必须是 str: {key!r}")
if key.startswith(_META_RESERVED_PREFIX):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 使用了保留前缀 {_META_RESERVED_PREFIX!r}"
"(留给库将来的内建维度,避免与调用方的键撞名)"
)
if not _META_KEY_RE.fullmatch(key):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 不合法: 只允许小写字母/数字/下划线/点,长度 1-64"
)
def _validate_meta_values(meta: Mapping[str, Any], origin: str) -> None:
"""值只收扁平标量;非有限 float 必须挡在这里。
`json.dumps` 会把 `nan`/`inf` 写成 `NaN`/`Infinity` 字面量不是合法 JSON,
PG JSONB 拒收放行则写入失败会被遥测的降级 try 吞成 warning,即把调用方
的输入错误转成静默丢遥测(Codex 审查推翻了初稿"序列化不可达"的论断)
"""
for key, value in meta.items():
if not isinstance(value, (str, int, float, bool)):
raise ValueError(
f"{origin} 的 meta 值必须是 str/int/float/bool: {key}={value!r}"
"(嵌套结构请调用方自行序列化)"
)
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"{origin} 的 meta 值不得是 nan/inf: {key}={value!r}(非合法 JSON)")
if isinstance(value, str) and len(value) > _META_VALUE_MAX_LEN:
raise ValueError(
f"{origin} 的 meta 值超长(上限 {_META_VALUE_MAX_LEN}): {key}{len(value)}"
)
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
return {**extra_body, **sampling}
@@ -71,6 +170,110 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
class Effort(StrEnum):
"""推理强度档位的封闭词汇(设计 §3.1)。
取值直接写进请求体(`reasoning_effort` 等键),**改名即改变发出去的字节**,
且会进缓存 key 与遥测落库,历史数据会断层
八档而非六档: `none`(不推理) `auto`(推理,档位由模型自定)必须同时存在
`auto` 不可省newapi 26 个可调用模型里有 9 个是**纯开关型**(qwen 五个
MiniMax-M3glm-5/5.1/4.6v),它们能开推理却没有强度档可填;没有 `auto` 就只
能拿某个强度档冒充"",而那正是本次要修的病根( `thinking_on` 硬编码
`medium`, `medium` GLM/kimi/deepseek 的档位表里根本不存在)
词汇取四家参考实现共同收敛的一套(cherry-studio canonical selection
OpenRouter `supported_efforts`LiteLLM `reasoning_effort_levels`
new-api `relayconvert/reasoning`),不自创
**枚举定义在最内层而非决策层**: 它是 `SourceConfig`/`ChatRequest`/
`LLMResponse` 的字段类型,放进 `thinking.py` 会让 `types.py` 反向 import
决策模块(P7 依赖铁律), `ThinkingObservation` 同一理由
"""
NONE = "none"
AUTO = "auto"
MINIMAL = "minimal"
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
XHIGH = "xhigh"
MAX = "max"
EFFORT_ORDER: tuple[Effort, ...] = (
Effort.NONE,
Effort.MINIMAL,
Effort.LOW,
Effort.MEDIUM,
Effort.HIGH,
Effort.XHIGH,
Effort.MAX,
)
"""由弱到强的强度序;`AUTO` **不在其中**——它是"由模型自定",在强弱轴上没有位置。
供能力表求"最省的开启档" `nearest` 映射取最近档公开( `_` 前缀)是因为
`thinking.py` 要跨模块消费它,跨模块引用私有名是坏味道
"""
def coerce_effort(raw: Any, *, origin: str) -> Effort:
"""把外部传入的档位**归一**成 `Effort`;非法值报 `ValueError` 并列全八档。
存在的理由是"归一化点必须在入口":库内一律用 `is Effort.NONE` 做身份比较
(枚举成员唯一,`is` `==` 更能表达"就是这一档"), `Effort` `StrEnum`
下游从 JSON/配置/命令行读出来的天然是裸字符串,`"none" is Effort.NONE`
恒为假不在入口归一,身份比较就会在**错误路径上**误判(把一致的配置判成
矛盾),随后拼错误文案时再 `.value` `AttributeError`,连承诺的 `ValueError`
都拿不到(2026-09-05 独立验证实测)
故裸字符串**接受并归一**而非拒收: 拒收会把 `.env` 之外的两条装配路(工厂 /
构造函数全量注入,CLAUDE.md §4.5)口径劈成两半, `.env` 那条早已是"解析即
归一"。`strip().lower()` 与 `config._to_effort` 同口径,理由同样是配置里的
行尾空格与大写写法是常态,而档位取值本身没有大小写语义
`origin` 指回具体的配置项或调用点: 档位在源级请求级两处都能配,只说
"非法档位"要人自己去找是哪一处填错了传空串表示调用方自己会补上下文
(`config._cast` `配置 X 解析失败` 已经说了是哪个 env )
"""
if isinstance(raw, Effort):
return raw
prefix = f"{origin}: " if origin else ""
listed = ", ".join(e.value for e in Effort)
if isinstance(raw, str):
try:
return Effort(raw.strip().lower())
except ValueError:
# 不 `from exc`: 枚举原生的 "'lowest' is not a valid Effort" 只是同一
# 件事的英文复述,链上去反而把可操作的那句挤到后面
raise ValueError(f"{prefix}非法推理档位 {raw!r};允许: {listed}") from None
raise ValueError(
f"{prefix}推理档位必须是 Effort 或其字面量字符串,"
f"收到 {type(raw).__name__}: {raw!r};允许: {listed}"
)
class ThinkingObservation(StrEnum):
"""一次调用中"推理是否真的发生"的裁定结果(issue #16/#17)。
三态**不可折叠为布尔**: `UNKNOWN` "本次无任何信号,判不出来",
`ABSENT`("上游明确上报了未推理")语义不同把前者折叠进后者,正是
`reasoning_tokens=None` 制造的那个歧义库据此静默宣称"没推理",而实际
可能推理了且已计费(MiniMax-M3 非流式实测: completion 53 vs 关闭档 3,
推理正文与 usage 明细双双不回传)
裁定由 `thinking.observe_thinking` ,本类只是取值域**枚举定义在最内层
而非决策层**: 它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让
`types.py` 反向 import 决策模块(P7 依赖铁律)
取值进遥测落库,改名即造成历史数据断层
"""
OBSERVED = "observed"
ABSENT = "absent"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -107,7 +310,31 @@ class LLMResponse:
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
6:4 双峰)实测三家供应商在未推理时都是整个 details 缺失无人上报 `0`,
故下游判据须为 `in (None, 0)`, `== 0` 的条件永远不成立"""
故下游判据须为 `in (None, 0)`, `== 0` 的条件永远不成立
**该口径 2026-08-25 作废**(issue #16/#17): 供应商可能整体停报
`completion_tokens_details`(MiniMax 这一路实测已停),此时 `None` 只意味着
没上报而非没推理同一次调用里库拿得到 185 字符推理正文有没有
推理一律改读 `thinking_observation`,上面那段只用于解读本版之前的历史数据"""
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的三态裁定(issue #16/#17)。
`UNKNOWN` = **本次无任何信号,判不出来**,**不是**"没推理"把两者折叠
`reasoning_tokens=None` 制造的老歧义典型来源: 非流式路径下部分模型
推理已计费却既不回传正文也不回传 `completion_tokens_details`(MiniMax-M3
实测开启档 completion 53 vs 关闭档 3),该档即为 `UNKNOWN`
要判"确实没推理"只认 `ABSENT`(上游明确上报 0)"""
applied_effort: Effort | None = None
"""本次调用**真正发出去**的推理档位(issue #20);`None` = 调用方未表态。
`ChatRequest.reasoning_effort`(请求档)可能分叉: 源上配了
`EFFORT_FALLBACK=nearest` ,请求 `medium` 而模型只有 low/high/max,实际发
出的是 `low`遥测按本字段分组,记请求档会把整行挂在一个从未发出过的档下
`None` 不是"没推理": 库不表态时也不推定模型自己的默认档"没看见"不许说成
"发生了"( `thinking_observation` `UNKNOWN` 一脉)"""
@dataclass(frozen=True)
@@ -129,6 +356,32 @@ class ChatRequest:
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
同一列在不同行口径分叉"""
# —— 调用方自定义维度(issue #11;追加在末尾,不扰动既有字段的位置构造)——
tenant_id: str | None = None
"""调用方的租户标识,进遥测的 `tenant_id` 真实列(issue #11)。
独立成字段而非混进 `meta`,因为它是唯一享有真实列待遇的维度可挂 RLS
可进复合索引混在 `meta` 里则调用方拼错(`tenantId`)不会报错,只会静默
降级成一个普通维度,正是本 issue 抱怨的失败形态"""
meta: Mapping[str, Any] = field(default_factory=dict)
"""调用方自定义维度的只读快照,库不解释其含义,库内中间件**永不修改**。
**不进缓存 key**: 租户隔离已由 `cache_namespace` 负责并已进 key(ARCH §7.5),
再进一次既重复又会让存量缓存全量冷启动; `meta` 承载的是审计维度而非
语义维度, messages namespace 下换个 batch_id 不应导致 miss"""
# —— 请求级推理档位(issue #20;追加在末尾,不扰动既有字段的位置构造)——
reasoning_effort: Effort | None = None
"""本次调用要求的推理档位,压过源级默认(设计 §4.2 的最高优先级层)。
`None` **不表态**(随源级配置), `Effort.NONE`("要求不推理")严格区分:
把前者读成后者会让一次没写档位的调用悄悄关掉源上配好的推理
独立成字段而非塞进 `overlay`: `overlay` 是采样参数的直通层,库不解释其内容,
而档位要经能力表校验要进缓存 key要落遥测混进直通层等于放弃这三样,
正是 issue #20 里下游手写 `extra_body` 绕过全部治理的那条路。"""
@dataclass(frozen=True)
class Usage:
@@ -148,6 +401,31 @@ class SourceStats:
tpm_used: int
@dataclass(frozen=True)
class TelemetryStatus:
"""遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。
不叫 `health`: 库内 `health` 一律指**源的健康度**(`OcrTransport.check_health`
探活`SourceSelector.health` 成功率 EWMA),而这里描述的是"这个 recorder
现在能不能写为什么不能丢了多少",是状态不是评分(设计 §3.3)。
时长一律给**相对秒数**而非绝对时间戳: 库内的时钟是 monotonic,把它的读数
交给下游会与 wall clock 混淆成两个不可比的时间轴
"""
degraded: bool
fatal: bool
"""True = 本进程内不可恢复(仅 DSN 不可解析一类配置级失败),需改配置并重启。"""
reason: str | None
"""降级原因;未降级为 None。"""
degraded_for_s: float | None
"""已降级时长;未降级为 None。"""
dropped_rows: int
"""累计丢弃行数;**进程生命周期内单调不减**——恢复不等于没丢过。"""
retry_after_s: float | None
"""距下次重新准备的秒数;fatal 或未降级为 None,冷却已到期为 0.0。"""
@dataclass(frozen=True)
class TransportResult:
"""transport 单次原始调用的产物;治理字段由 RetryMW 补齐为 LLMResponse。"""
@@ -164,6 +442,18 @@ class TransportResult:
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding )沉默
,不该替上游做出"没推理"这个它从未做过的声明"""
applied_effort: Effort | None = None
"""本次调用真正发出去的推理档位(issue #20),由做注入的 transport 填。
只有做了注入的那一层知道它: `nearest` 映射后请求档与实际档分叉(请求
`medium` 实发 `low`),中间件事后再算一遍必然算成请求档默认 `None`
"未表态/不注入推理参数"(OCRembedding transport 沉默即此)"""
@dataclass(frozen=True)
@@ -172,6 +462,10 @@ class SourceConfig:
限额闸 0 表示不启用;`enable_thinking` 三态: None=不注入(模型默认)
True=注入开启参数False=注入关闭参数(统一 VT CHS 相反的现状)
2026-09-04 `enable_thinking` 降级为 `reasoning_effort` 的语法糖
(`True``AUTO``False``NONE`),保留不删是因为它已被三项目消费
(迁移兼容约束,ARCH §5.1)两个字段说的是同一件事,故矛盾即报错
"""
name: str
@@ -196,10 +490,29 @@ class SourceConfig:
(加任何 mapping 字段的固有代价, dict 亦然),库内无以源作 key 的写法;
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`"""
reasoning_effort: Effort | None = None
"""本源默认的推理档位;None = 不表态(与 `Effort.NONE`「要求不推理」不同)。
裸字符串(`"low"``" LOW "`)也收,构造期由 `coerce_effort` 归一成 `Effort`,
非法值当场 `ValueError` 并列出八档;**构造完成后本字段一定是 `Effort`**,库内
`is Effort.NONE` 身份比较依赖这条不变式
**追加在末尾**是硬要求:三项目的测试按位置构造 fake,插在中间会静默错位
(本模块头部 docstring 的字段保序约定)"""
effort_fallback: str = "error"
"""请求档打空时的处置: `error`(默认,报错)或 `nearest`(映射到最近的档)。
默认报错的理由是钱: 一次静默的 `medium max` GLM-5.3 上是数倍账单
(P5严禁默认值掩盖错误)值域在此把关而非交给 `resolve_thinking`
后者对未知值是 fail-closed( `error` 处理),不会替配置兜错,漏判的结果
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效"""
def __post_init__(self) -> None:
self._validate_identity()
self._validate_gates()
self._validate_watchdog()
self._validate_thinking()
self._freeze_extra_body()
def effective_est_tokens(self) -> int:
@@ -237,6 +550,52 @@ class SourceConfig:
):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_thinking(self) -> None:
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
归一化必须先于下面的矛盾判定: 判据用的是 `is Effort.NONE`,而本类是公共
入口,`reasoning_effort="none"` 这种裸字符串写法( JSON/配置读出来的
常态)会让它误判成矛盾,再拼文案时 `.value` 直接 `AttributeError`同一
理由也适用于下游读侧归一化后库内一律是 `Effort`,`is` 比较才安全
矛盾**报错而非后者赢**: `enable_thinking` `reasoning_effort` 表达的是
同一件事,静默取其一等于替下游猜它到底想要哪个,而猜错的代价是账单
猜成开启就是白花钱,猜成关闭就是拿到一个没推理过的答案
判据是二者是否都在说关闭: `enable_thinking is False`
`reasoning_effort is NONE` 必须同真同假`True` + 某个开启档( `low`)
不算矛盾,那只是把同一件事说了两遍,且后者更精确
"""
if self.reasoning_effort is not None:
# frozen dataclass 改字段走 object.__setattr__(同款先例: _freeze_extra_body)
object.__setattr__(
self,
"reasoning_effort",
coerce_effort(
self.reasoning_effort, origin=f"SourceConfig({self.name}).reasoning_effort"
),
)
if isinstance(self.effort_fallback, str):
# 与相邻的 `REASONING_EFFORT` 同口径: `.env` 里的行尾空格与大写写法是
# 常态,而 `nearest`/`error` 本身没有大小写语义。归一化放在值域校验的
# 同一处(而不是 env 解析处),三条配置路一并覆盖
object.__setattr__(self, "effort_fallback", self.effort_fallback.strip().lower())
if self.effort_fallback not in _EFFORT_FALLBACK_DOMAIN:
raise ValueError(
f"SourceConfig.effort_fallback(EFFORT_FALLBACK)非法值 "
f"{self.effort_fallback!r};允许: {sorted(_EFFORT_FALLBACK_DOMAIN)}"
)
if self.enable_thinking is None or self.reasoning_effort is None:
return
if (self.enable_thinking is False) != (self.reasoning_effort is Effort.NONE):
raise ValueError(
f"{self.name!r} 的 enable_thinking={self.enable_thinking}"
f"reasoning_effort={self.reasoning_effort.value!r} 相互矛盾: "
f"enable_thinking 已是 reasoning_effort 的语法糖"
f"(True={Effort.AUTO.value}、False={Effort.NONE.value})。"
f"请只保留其中一个,或让两者语义一致"
)
def _freeze_extra_body(self) -> None:
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
validated = validate_request_overlay(
+44
View File
@@ -291,6 +291,50 @@ class TestRetryAfter:
await _open_gate(gate, "s1") # s1 开路;s2 健康
assert await gate.retry_after_s(("s1", "s2")) == 0.0
async def test_half_open_rejection_reports_no_certain_wait(self, gate_factory, clock):
"""探针在途时被拒 → 0.0(issue #14): 探针随时可能出结果,不存在确定时刻。
旧行为返回探针租约剩余,而租约长度是**死锁保护参数**(派生自
`2 × 最慢源 timeout`),"这个源多久能恢复"没有因果关系现场
`TIMEOUT_S=300` 时它是 600s,而冷却期只有 60s
"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.is_probe
blocked = await gate.try_enter("s1", "w2")
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
assert blocked.retry_after_s == 0.0
async def test_probe_grant_reports_no_certain_wait(self, gate_factory, clock):
"""准入被允许 → 恒 0.0(现在就能试);此前 redis 侧返回探针 TTL。"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.allowed and probe.is_probe
assert probe.retry_after_s == 0.0
async def test_retry_after_zero_while_probe_in_flight(self, gate_factory, clock):
"""集合查询同口径: 探针在途的源不贡献等待时间。"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe
assert await gate.retry_after_s(("s1",)) == 0.0
async def test_fenced_write_in_half_open_reports_no_certain_wait(self, gate_factory, clock):
"""写回被 fencing 拒时的快照同口径;此前 redis 侧返回探针租约剩余。"""
gate = gate_factory(_CFG)
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
await _open_gate(gate) # 他人开路,epoch 推进
clock.advance(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
update = await gate.record_success(stale)
assert not update.applied and update.state is GateState.HALF_OPEN
assert update.retry_after_s == 0.0
class TestConsecutiveSuppression:
"""迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。"""
+14 -4
View File
@@ -18,9 +18,16 @@ _REPO = Path(__file__).resolve().parents[2]
_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)
pytestmark = pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而
# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了
# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
),
]
@pytest.fixture
@@ -69,7 +76,10 @@ class TestVideoTreeOnboarding:
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
flat_env = {
**source_keys,
"LLM_TIMEOUT": "120",
# 与 .env 的 LLM__MINIMAX__1__TIMEOUT_S 同值。取 120(VT 旧值)会让本用例的
# 超时比生产配置还紧一半,在慢网关上必然间歇红——而本用例断言的是平铺
# 键名能否解析成 SourceConfig.timeout_s,超时取值本身不是被测对象
"LLM_TIMEOUT": "300",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
+9 -3
View File
@@ -21,9 +21,15 @@ from polygateway.types import SourceConfig
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
pytestmark = pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺真实网关配置(.env)"
)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV,
reason="缺真实网关配置(.env)",
),
]
_OUT = Path("tests/outputs/embedding")
+9 -3
View File
@@ -19,9 +19,15 @@ from polygateway import GatewayClient
_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)
pytestmark = pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)"
)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE,
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
),
]
_OUT_DIR = Path("tests/outputs/e2e")
+676 -47
View File
@@ -1,51 +1,63 @@
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
"""真实 API 验证推理开关与推理可观测性(issue #5 + #6;判据于 #16/#17 重建)。
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
验证不了"这个参数在这个模型上到底关没关掉推理"
条判据纪律(来自 findings §4c 的实测教训):
条判据纪律( 12 条来自 findings §4c, 1 条的推翻与第 3 条来自
`findings/2026-08-25-thinking-observability-regression.md`):
1. **判别量只能 `reasoning_tokens`, `completion_tokens`** 两档的输出
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
开启档最低 13 token(medium 档想得少的那几轮),长度阈值判两边都会误判
`reasoning_tokens` 在同一批 30 轮里干净分开关闭 15/15 None,
开启 15/15 大于 0
2. **另配一个不含魔数的确定性锚点**( L2b): 同一模型上,关闭档的
1. **判别量是库裁定的三态 `thinking_observation`,不是 `reasoning_tokens`
也不是 `completion_tokens`** 长度判据早已排除: 两档的输出长度分布**
重叠的**(实测关闭档最高 46 token开启档最低 13 token),按阈值判两边都会
误判 `reasoning_tokens` 这个曾经"干净分开"的判据也已失效MiniMax
这一路上游不再返回 `usage.completion_tokens_details`,该字段恒 `None`;同一
次调用里库明明拿得到 185 字符推理正文,单看 token 计数却把"推理正常"读成
"没推理"(2026-08-25 findings §3.4/结论③,四条用例因此假红)三态裁定同时
看正文与计数: **正文是事实本身,token 计数只是对事实的转述**
2. **另配一个不含魔数的确定性锚点**( L2bL5): 同一模型上,关闭档的
`prompt_tokens` 严格小于开启档供应商在开启时注入了推理指令,输入侧
token 数随之变大这是相对比较,不硬编码任何具体数值
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足** 中转在上游不返回
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
开启方向因此可能偶尔观测不到;关闭方向不受影响
token 数随之变大这是相对比较,不硬编码任何具体数值;且它不依赖上游是否
回传推理正文,所以在"观测不到推理"的非流式路径上依然作数
3. **`UNKNOWN` 不等于"没推理",不能拿它判红** 关闭方向要求每轮"未观测到
推理"(`UNKNOWN` 计入满足——它没有证伪力),其证伪力来自: 模型若偷偷推理了,
可观测路径会翻成 `OBSERVED`开启方向只要求多数轮 `OBSERVED`;M3 非流式
路径整片观测不到,该档由 L5 用另一套断言覆盖
源不可用一律 `skip` 并在报告中记为未覆盖,**绝不静默计入通过**
"""
import asyncio
import dataclasses
import json
import os
from collections import Counter
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from types import MappingProxyType
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings
from polygateway import GatewayClient, GatewaySettings, ThinkingObservation
from polygateway.errors import (
AllSourcesExhausted,
GatewayUnavailableError,
RequestRejectedError,
SourceDeadError,
TransientError,
)
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
from polygateway.thinking import DEFAULT_CAPABILITIES, ThinkingCapability, get_capability
from polygateway.types import EFFORT_ORDER, Effort
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# slow: 本组 137 次真实调用、约 7钟,且判据是统计性的——网络抖动会让它偶发
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
# 存档报告。"不自动门控"不等于"可跳过"。
# slow: 本组 92 次真实调用、约 4半(2026-08-26 判据换三态后实测;此前记的
# "137 次、约 7 分钟"已被证伪,别照旧值估 CI 预算),且判据是统计性的——网络抖动
# 会让它偶发失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci`
# 会把测试变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow`
# 显式真跑并存档报告。"不自动门控"不等于"可跳过"。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
@@ -60,9 +72,6 @@ _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
_ON_MIN_COMPLETION = 100
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
_ROWS: list[dict] = []
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
@@ -73,7 +82,28 @@ _MODEL_PROVIDER = {
"MiniMax-M2.7": "minimax",
"MiniMax-M2.5": "minimax",
"qwen3.7-plus": "qwen",
"qwen3.7-max": "qwen",
"qwen3.6-plus": "qwen",
"qwen3.5-flash": "qwen",
"qwen-plus-latest": "qwen",
"deepseek-v4-pro": "deepseek",
"deepseek-v4-flash": "deepseek",
"deepseek-v4-flash-vision-exp": "deepseek",
"glm-5.3": "zhipu",
"glm-5.3-flash": "zhipu",
"glm-5.2": "zhipu",
"glm-5.1": "zhipu",
"glm-5": "zhipu",
"glm-4.6v": "zhipu",
"kimi-k3": "moonshot",
"kimi-for-coding": "moonshot",
"gpt-5.4": "openai",
"gpt-5.5": "openai",
"claude-opus-5": "anthropic",
"claude-sonnet-5": "anthropic",
"claude-haiku-5": "anthropic",
"gemini-3.1-pro": "google",
"gemini-3-flash": "google",
}
@@ -107,6 +137,10 @@ async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
# 结论与证据一起入报告: 只记 observation 会让"为什么这么判"
# 不可复核,而 thinking_chars 正是本次改判的直接证据
"thinking_observation": resp.thinking_observation,
"thinking_chars": len(resp.thinking),
"content": resp.content[:60],
}
)
@@ -128,26 +162,32 @@ def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -
def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只看 reasoning_tokens
"""关闭方向: 只要没观测到推理即算满足
`UNKNOWN` 计入满足是有意的: 它没有证伪力(本次无任何信号,判不出来),拿它
判红等于每次关闭调用都喊一遍本判据真正的证伪力在于模型若偷偷推理了,
可观测路径会把裁定翻成 `OBSERVED`
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理加长度门只会
把这种正常波动误判成"没关掉"
"""
return obs["reasoning_tokens"] in (None, 0)
return obs["thinking_observation"] != ThinkingObservation.OBSERVED
def _reasoning_on(obs: dict) -> bool:
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据
"""开启方向: 观测到推理即为真
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据
判据从 `reasoning_tokens` 换成库的三态裁定,因为 MiniMax 这一路已不再上报
`completion_tokens_details`(2026-08-25 findings 结论②),该字段恒 `None`;
而库在同一次调用里拿得到 185 字符推理正文(findings §3.4)旧判据看不见
,L2/L3b/L4/L5 四条因此假红
也不能退回 completion_tokens 当判据: medium 档的推理量方差极大(实测 15
7-170 token),两档分布还与关闭档重叠,按长度阈值判会把"推理了但想得少"
误判成没推理
"""
reasoning = obs["reasoning_tokens"]
if reasoning is not None:
return reasoning > 0
return obs["completion_tokens"] > _ON_MIN_COMPLETION
return obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
@@ -163,14 +203,18 @@ def _write_report():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
lines = [
"# 推理开关与 reasoning_tokens 真实 API 验证",
"# 推理开关与推理可观测性真实 API 验证",
"",
f"- 时间: {ts}",
f"- 每档轮数: {_ROUNDS}",
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"- 判别量: 库裁定的三态 `thinking_observation`(OBSERVED/ABSENT/UNKNOWN),"
"由推理正文与 reasoning_tokens 共同裁定 —— 正文是事实,token 计数只是转述",
"- 关闭判据: **轮** observation != OBSERVED(UNKNOWN 计入满足,它没有证伪力);"
"刻意不设输出长度上限(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
"- 开启判据: **多数轮** observation == OBSERVED",
"- 确定性锚点(L2b、L5): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"- L5(非流式): M3 该路径推理已计费却不回传正文,故不断言「观测到推理」,"
"改断锚点可分 + 开启档不被误判为 ABSENT",
"",
"## 矩阵结论",
"",
@@ -206,7 +250,7 @@ class TestMiniMaxM3:
"L1",
"enable_thinking=False(流式)",
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理",
f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs,
)
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
@@ -256,7 +300,7 @@ class TestMiniMaxM3:
"L3",
"enable_thinking=None(不干预,基线)",
"PASS" if len(quiet) == len(obs) else "FAIL",
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
f"{len(quiet)}/{len(obs)} 轮未观测到推理(M3 默认档本就不推理)",
obs,
)
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
@@ -271,6 +315,12 @@ class TestMiniMaxM3:
判别方法: 发一个**非法值**若未知值会被静默丢弃,它的表现应与"不注入"
一致(不推理);实测它反而开启了推理,说明网关认这个键只是不认这个值
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值
**该手法不可移植,只对"认这个键但不校验值" provider 成立**: minimax
非法 `reasoning_effort` 返回 200 且照常推理(2026-08-25 findings §5:
prompt 207,介于基线 194 medium 216 之间,走了第三条模板路径); qwen
对同样的值直接返回 **HTTP 400**把本用例套到 qwen 那类会校验值的 provider
,拿到的会是异常而非"不推理",是假红
"""
rounds = max(3, _ROUNDS // 3)
bogus = await _run_rounds(
@@ -318,23 +368,50 @@ class TestMiniMaxM3:
)
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
async def test_l5_non_stream_path_matches_stream(self):
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
async def test_l5_non_stream_path_is_distinguishable_and_honestly_unknown(self):
"""非流式快路径: 参数确实到达了模型,而推理信号被如实标成"观测不到"
**本用例不能断言"非流式开启档观测到推理"那永远不成立**: M3 在非流式
路径下推理段确实产生并计费(2026-08-25 findings §3.4: 开启档 completion 53
vs 关闭档 3), `message` 里没有 `reasoning_content``usage` 里也没有
`completion_tokens_details`,推理内容整体不回传**这是上游行为,库修不了;
库能做也必须做的是让它可见**下游在为看不见的东西付费,不该由库替它
沉默
故改断两件在非流式下真实成立的事:
其一 `prompt_tokens` 锚点仍把两档分开(判据形态照抄 L2b,证明注入到达了模型,
排除"非流式路径把参数弄丢了"这一伪解释);
其二开启档的裁定**不是 `ABSENT`**`ABSENT` 的语义是"上游明确上报未推理",
而实情是"判不出来"(`UNKNOWN`),库若把后者伪装成前者,正是 issue #16/#17 里
那个静默错觉这里断 `!= ABSENT` 而非 `== UNKNOWN`,是为了留出上游哪天开始
回传正文的余地: 那时裁定会翻成 `OBSERVED`,是好事,不该让它把测试判红
"""
rounds = max(3, _ROUNDS // 2)
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
offs = [o for o in off if _reasoning_off(o)]
ons = [o for o in on if _reasoning_on(o)]
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
off_max = max(o["prompt_tokens"] for o in off)
on_min = min(o["prompt_tokens"] for o in on)
not_absent = [o for o in on if o["thinking_observation"] != ThinkingObservation.ABSENT]
on_states = Counter(str(o["thinking_observation"]) for o in on)
ok = len(offs) == len(off) and off_max < on_min and len(not_absent) == len(on)
_record(
"L5",
"非流式路径重跑 L1/L2",
"非流式: prompt 锚点可分 + 开启档如实标 UNKNOWN 而非 ABSENT",
"PASS" if ok else "FAIL",
f"关闭 {len(offs)}/{len(off)},开启 {len(ons)}/{len(on)}",
f"关闭 {len(offs)}/{len(off)}未观测到推理;"
f"关闭档 prompt 最大 {off_max} < 开启档最小 {on_min};"
f"开启档裁定分布 {dict(on_states)}",
off + on,
)
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
assert off_max < on_min, (
f"非流式两档 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): "
f"开启参数可能没到达模型"
)
assert len(not_absent) == len(on), (
f"非流式开启档被裁成 ABSENT(声称上游明确上报未推理),而实情是观测不到: {on}"
)
class TestOtherProviders:
@@ -357,11 +434,36 @@ class TestOtherProviders:
matrix,
desc,
"PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理",
f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs,
)
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
async def test_qwen_enabled_is_observed(self):
"""设计 §14 验收: qwen 开启档必须裁定为 `OBSERVED`,不是 `UNKNOWN`。
本条是三态裁定的**跨供应商对照组**: MiniMax 这一路两个信号都可能缺失
(非流式档整片 `UNKNOWN`),若只按它调判据,很容易把"观测不到"当成常态;
qwen 在同一网关同一 key 上照常返回推理信号(findings 2026-08-25 §2),
故这里能且必须要求正面结论它一旦掉成 `UNKNOWN`,说明的是库的组装路径
丢了信号,而不是上游行为变了
"""
matrix, provider, model = "L6b", "qwen", "qwen3.7-plus"
desc = f"{provider} enable_thinking=True"
try:
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=True)
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
_skip_if_unreachable(exc, matrix, desc)
ons = [o for o in obs if _reasoning_on(o)]
_record(
matrix,
desc,
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
f"{len(ons)}/{len(obs)} 轮观测到推理(OBSERVED)",
obs,
)
assert len(ons) * 2 > len(obs), f"{provider} 开启方向要求多数轮 OBSERVED: {obs}"
class TestCapabilityDrift:
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
@@ -395,7 +497,7 @@ class TestCapabilityDrift:
"L8",
desc,
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
f"实测未观测到推理 {dict(verdict)}(True=满足);声明 can_disable=True 要求每轮满足",
obs,
)
assert len(offs) == len(obs), (
@@ -438,7 +540,534 @@ class TestAssemblyGuardAgainstRealConfig:
stream=True,
overlay={},
call_id="e2e-guard",
reasoning_effort=None,
)
finally:
await client.aclose()
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
# ══════════════════════════════════════════════════════════════════════════════
# T10: 逐模型档位实测(方法论沿用 issue #20)
#
# 本节与上面的 L1-L9 分工不同: 上面验的是**库的行为**(注入到没到、观测准不准),
# 这里验的是**能力表的内容**(`DEFAULT_CAPABILITIES` 里那 20 多条声明是不是真的)。
# 二者判据可以共用,数据源却必须分开——能力表实测要**绕过能力表**才有意义,
# 否则拿待验证的声明去挡请求,等于用结论证明前提。
#
# 判据(三条,均沿用已有纪律):
# ① 关闭方向: 每轮 `thinking_observation != OBSERVED` 才算真关掉;任一轮
# OBSERVED 即证伪(推理正文是事实本身,不需要多数票)。
# ② **短提示词的"关掉了"必须经长上下文复核**: issue #20 实测 GLM 系在短提示词
# 下 reasoning_tokens≈1.2 像是关了,5552 token 长上下文下跳到 0/54/167 即露馅。
# 短提示词下推理量本就趋近于 0,分不出"关了"与"没什么可想的"。
# ③ 开启方向: 多数轮 OBSERVED(单轮抖动不判红,与 L2 同口径)。
# ④ 关闭结论**不许只靠 `UNKNOWN`**: 上游整片不回传推理信号时(kimi、MiniMax 两路
# 都是),"没看见"不是"没发生"。此时补一个不含魔数的锚点——关闭档的
# `completion_tokens` 必须严格小于 `max` 档,否则结论记为「判不出来」。
# ══════════════════════════════════════════════════════════════════════════════
_TIER_OUT_DIR = Path("tests/outputs/thinking")
_TIER_ROUNDS = int(os.environ.get("PGW_E2E_TIER_ROUNDS", "5"))
_TIER_LONG_ROUNDS = int(os.environ.get("PGW_E2E_TIER_LONG_ROUNDS", "3"))
# 共用生产网关,宁慢勿冲(人类 2026-09-05 指令): 默认 3,可下调不建议上调
_TIER_CONCURRENCY = int(os.environ.get("PGW_E2E_TIER_CONCURRENCY", "3"))
# 固定短提示词: 答案本身约 4 token,推理 token 的信噪比高(issue #20 同款)
_TIER_PROMPT = "23 乘以 47 等于多少?只回答一个数字,不要解释。"
# 长上下文对照组(判据②)。填充文本与题目无关且不含任何业务领域词汇(零业务假设
# 铁律),只为把输入撑到数千 token;题目放在最后,避免被当成"读完就忘"的前缀
_TIER_LONG_PROMPT = (
"\n".join(
f"{i:04d}. 这是一段与题目无关的填充文字,仅用于把上下文撑到数千 token,"
"以复核短提示词下得到的关闭结论在长上下文下是否依然成立。"
for i in range(120)
)
+ "\n\n"
+ _TIER_PROMPT
)
_ALL_EFFORTS: tuple[Effort, ...] = (*EFFORT_ORDER, Effort.AUTO)
_PROBE_ROWS: list[dict] = []
def _tier_settings(model: str) -> GatewaySettings:
"""探测用配置: 生产口径的超时,但**重试预算压到 1 次**。
压重试是因为探测里"这一轮失败"本身就是数据(逐轮进报告),库替它重试只会
"渠道当下不可用"变成三倍等待2026-09-05 实测 claude 7 天限额用尽时
每轮 429,三次重试让单个模型阻塞三分钟以上,26 个模型跑不完
**单次请求的超时不动**(仍是 .env 的生产值 300s): §4.6 那条"测试超时不得紧于
生产配置"防的是把慢而正常的模型误判成不可用,那个风险在这里照旧存在。重试次数
与背压窗口不属于同一类它们决定"失败之后还等多久",而不是"多慢算失败";
一个真在出字的模型永远碰不到这两者
"""
base = GatewaySettings.from_env(
"LLM",
env={
**_ENV,
"PGW_CACHE_BACKEND": "none",
"LLM_MAX_RETRIES": "1",
# 探测是**单源**的,没有别的源可换。生产值 1200s 的 stall window 在这里
# 只会把"这个模型当下不可用"拖成 20 分钟一轮: 2026-09-05 实测 claude 系
# 7 天限额用尽返回 429 且不带 Retry-After,库据此判"无可运行源"并按背压
# 语义等到窗口耗尽(实测把窗口调到 45s 即在 46.7s 报 stalled)。多源生产
# 场景下这段等待是有意义的(等别的源恢复),探测场景下等不到任何东西
"LLM__BACKPRESSURE__STALL_WINDOW_S": "60",
},
)
source = dataclasses.replace(
base.sources[0],
provider=_MODEL_PROVIDER[model],
model=model,
enable_thinking=None,
reasoning_effort=None,
)
return dataclasses.replace(base, sources=(source,))
def _probe_capabilities(model: str) -> dict[str, ThinkingCapability]:
"""临时全档能力表: **实测的对象正是能力表本身**,不能拿它当前提去挡请求。
不传 `capabilities={}`("未登记")的理由是噪声: 那条路会走 Phase 3,每轮都
warning 一句"能力未登记",几百轮下来把真正的告警淹没全档表让五关全部放行,
请求原样发出去,由上游而不是由库来回答"这一档到底行不行"
"""
return {model: ThinkingCapability(_ALL_EFFORTS, evidence="T10 实测临时表(不进 DEFAULT)")}
async def _probe_effort(
model: str, effort: Effort, *, rounds: int, prompt: str, prompt_kind: str
) -> list[dict]:
"""对一个 (模型, 档位) 打 N 轮真实请求,逐轮记录;失败轮记 `error` 而不冒泡。
失败不冒泡是本函数与 `_run_rounds` 的唯一区别: 这里"上游拒绝这一档"本身就是
**实测结论**(HTTP 400 = 该档不被接受),把它抛出去会让数据采集半途而废
只吞四分类与 `AllSourcesExhausted`库自身的 `ValueError` 等仍然冒泡,那是
bug 不是数据
"""
client = GatewayClient.from_settings(
_tier_settings(model), capabilities=_probe_capabilities(model)
)
semaphore = asyncio.Semaphore(_TIER_CONCURRENCY)
async def _one(index: int) -> dict:
base = {"round": index + 1, "effort": effort.value, "prompt_kind": prompt_kind}
async with semaphore:
try:
resp = await client.chat(
[{"role": "user", "content": prompt}],
stream=True,
reasoning_effort=effort,
cache_salt=f"tier-probe-{model}-{effort.value}-{prompt_kind}-{index}",
)
except (
RequestRejectedError,
GatewayUnavailableError,
SourceDeadError,
TransientError,
) as exc:
# 捕 `GatewayUnavailableError` 而不是只捕 `AllSourcesExhausted`:
# 某个模型在网关上不通时,连续失败会把熔断门打开,后续轮次抛的是
# `CircuitOpenError`(同一父类的兄弟)。只捕子类会让"源不可用"这
# 件事在第 N 轮换个类型冒出去,把数据采集打断成一次红测
return {**base, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
return {
**base,
"error": None,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
"thinking_chars": len(resp.thinking),
"thinking_observation": resp.thinking_observation,
"applied_effort": resp.applied_effort,
# 核对模型身份: issue #20 记录本渠道对 glm-5.2 的请求 6/6 回报
# model=glm-5.3。凡结论依赖模型身份的,对不上即数据不可信
"model_reported": resp.model_reported,
"content": resp.content[:40],
}
try:
return list(await asyncio.gather(*(_one(i) for i in range(rounds))))
finally:
await client.aclose()
def _probe_ok(obs: dict) -> bool:
return obs["error"] is None
def _probe_quiet(obs: dict) -> bool:
"""成功且未观测到推理(判据①的满足条件);失败轮不算"安静",它没有观测。"""
return _probe_ok(obs) and obs["thinking_observation"] != ThinkingObservation.OBSERVED
def _probe_observed(obs: dict) -> bool:
return _probe_ok(obs) and obs["thinking_observation"] == ThinkingObservation.OBSERVED
def _rt_summary(observations: list[dict]) -> str:
"""报告里的一行摘要: rt 观测值序列 + 裁定分布 + 身份核对,三样缺一不可复核。"""
ok = [o for o in observations if _probe_ok(o)]
if not ok:
return f"全部 {len(observations)} 轮失败: {observations[0]['error']}"
rts = [o["reasoning_tokens"] for o in ok]
verdicts = Counter(str(o["thinking_observation"]) for o in ok)
reported = sorted({str(o["model_reported"]) for o in ok})
failed = len(observations) - len(ok)
tail = f";{failed} 轮失败" if failed else ""
return (
f"rt={rts};裁定 {dict(verdicts)};thinking_chars="
f"{[o['thinking_chars'] for o in ok]};model_reported={reported}{tail}"
)
# 已知的合法别名: 供应商回报的名字与配置里的别名本就可以不同(月之暗面回
# `k3`、Google 回 `-preview` 后缀)。**显式登记而不是按前缀猜**——猜的话
# `glm-5.2 → glm-5.3` 这种真·串台也会被当成"同族别名"放过,而那正是本表要抓的
_MODEL_REPORTED_ALIASES: Mapping[str, frozenset[str]] = MappingProxyType(
{
"kimi-k3": frozenset({"k3"}),
"kimi-for-coding": frozenset({"k3"}),
"gemini-3-flash": frozenset({"gemini-3-flash-preview"}),
"gemini-3.1-pro": frozenset({"gemini-3.1-pro-preview"}),
}
)
def _identity_mismatch(model: str, observations: list[dict]) -> list[str]:
"""响应体里的 `model` 与请求的模型对不上 → 本次数据说的不是这个模型。
issue #20 就栽在这里: 该渠道对 `glm-5.2` 的请求 6/6 回报 `model=glm-5.3`,
照单全收的话,能力表里 glm-5.2 那一行记的其实是 glm-5.3 的行为凡结论依赖
模型身份的,对不上就必须当场作废,而不是打个折扣继续用
`None`(上游未上报)不算不符: 那是"没说",不是"说了别的"
"""
allowed = {model, *_MODEL_REPORTED_ALIASES.get(model, frozenset())}
return sorted(
{
o["model_reported"]
for o in observations
if _probe_ok(o)
and o["model_reported"] is not None
and o["model_reported"] not in allowed
}
)
async def _anchor_off_against_on(
model: str, off_observations: list[dict]
) -> tuple[list[dict], Effort | None, bool]:
"""判据④: 拿"开启档的 completion 明显更大"给关闭结论补一个正面证据。
需要它是因为 `UNKNOWN` 的语义: 它是"本次没有任何信号,判不出来",不是"没推理"
(`observe_thinking` docstring 把这条写死了)kimi MiniMax 这两路上游都
不回传 `completion_tokens_details`,关闭档整片 `UNKNOWN`此时若直接把"没看见"
读成"关掉了",库就会登记一个自己从未验证过的 `none`,而下游据此以为省了钱
锚点取 `completion_tokens` 的相对比较(关闭档最大值 < 开启档最小值),**不含
任何魔数**: 推理段计在 completion ,真开着时两档差一个数量级(实测 kimi-k3
关闭档恒 9 token) `max` 档而非 `auto`: 后者在 `on_base={}` provider
等于"什么都不注入",那是模型默认档而不是"",拿它当对照组会把 M3 这种默认不推理的
模型判成"分不开"(minimax 段已按 issue #21 改回带 medium,openai/anthropic/google
三段仍是空片段,故该风险仍在)`max` 打不通时才退到 `auto`
"""
off_usable = [o for o in off_observations if _probe_ok(o)]
for tier in (Effort.MAX, Effort.AUTO):
anchor = await _probe_effort(
model,
tier,
rounds=_TIER_LONG_ROUNDS,
prompt=_TIER_PROMPT,
prompt_kind=f"anchor({tier.value})",
)
on_usable = [o for o in anchor if _probe_ok(o)]
if not on_usable:
continue
off_max = max(o["completion_tokens"] for o in off_usable)
on_min = min(o["completion_tokens"] for o in on_usable)
return anchor, tier, off_max < on_min
return [], None, False
def _probe_record(model: str, phase: str, verdict: str, detail: str, observations: list[dict]):
_PROBE_ROWS.append(
{
"model": model,
"provider": _MODEL_PROVIDER[model],
"phase": phase,
"verdict": verdict,
"detail": detail,
"observations": observations,
}
)
@pytest.fixture(scope="module", autouse=True)
def _write_tier_report():
"""T10 报告独立成文件: 它的读者是"能力表该怎么改",与 L1-L9 的"库对不对"不同。"""
yield
if not _PROBE_ROWS:
return
_TIER_OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _TIER_OUT_DIR / f"tier_probe_{ts}.md"
lines = [
"# 推理档位能力表实测(T10,经 new-api 中转)",
"",
f"- 时间: {ts}",
f"- 短提示词轮数: {_TIER_ROUNDS};长上下文复核轮数: {_TIER_LONG_ROUNDS};"
f"并发: {_TIER_CONCURRENCY}(共用生产网关,宁慢勿冲)",
f"- 短提示词: `{_TIER_PROMPT}`",
f"- 长上下文: 同题 + {len(_TIER_LONG_PROMPT)} 字符无关填充(判据②)",
"- 判据: 关闭方向要求**每轮**未观测到推理,且短提示词的「关掉了」必须经长上下文复核;"
"开启方向要求多数轮 OBSERVED",
"- 能力表在探测时被临时替换为全档表: 实测的对象正是它,不能拿它挡请求",
"",
"## 逐模型结论",
"",
"| 模型 | provider | 阶段 | 结论 | 观测 |",
"|---|---|---|---|---|",
]
total = 0
for row in _PROBE_ROWS:
detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:220]
lines.append(
f"| {row['model']} | {row['provider']} | {row['phase']} | {row['verdict']} | {detail} |"
)
total += len(row["observations"])
lines += ["", f"**总真实调用次数: {total}**", "", "## 逐轮原始观测", ""]
for row in _PROBE_ROWS:
if not row["observations"]:
continue
lines += [f"### {row['model']}{row['phase']}", "", "```json"]
lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2, default=str))
lines += ["```", ""]
path.write_text("\n".join(lines), encoding="utf-8")
print(f"\n[T10 报告] {path}")
class TestTierProbe:
"""能力表实测。可只跑单个模型: `-k "test_t10 and glm-5.3"`。"""
@pytest.mark.parametrize("model", sorted(_MODEL_PROVIDER))
async def test_t10_none_direction_matches_declaration(self, model):
"""「这个模型到底关不关得掉」——能力表里唯一会**报错**的那条声明。
它是本节最要紧的一条: `Effort.NONE` 在不在清单里,决定 Phase 4 是放行还是
当场报错声明错了,两个方向的代价都很实在多写了 `none` 会让下游以为
关掉了(issue #20 的静默失效),漏写了会把一条本来可用的路堵死。
"""
short = await _probe_effort(
model, Effort.NONE, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short"
)
# 上游拒绝这一档(400)是**结论**而非故障: 它等价于"关不掉";
# 其余失败(渠道下线/超时)才是源不可用,按既有纪律记为未覆盖
rejected = [o for o in short if o["error"] and o["error"].startswith("RequestRejected")]
usable = [o for o in short if _probe_ok(o)]
# **按可用轮判,而不是一有失败就整条跳过**: 共用网关上偶发 429/503 是常态,
# 一票否决会让整张表因为一次抖动而没有数据。样本低于 3 轮才是真的没结论
if not rejected and len(usable) < min(3, _TIER_ROUNDS):
broken = [o for o in short if o["error"]]
_probe_record(model, "none 方向", "SKIP(源不可用)", _rt_summary(short), short)
pytest.skip(f"{model} 源不可用,已记为未覆盖: {broken[0]['error'][:120]}")
strangers = _identity_mismatch(model, short)
if strangers:
_probe_record(
model,
"none 方向",
"SKIP(身份不符,数据不可信)",
f"该渠道把请求回报成 {strangers};{_rt_summary(short)}",
short,
)
pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型")
observations = list(short)
measured_can_disable = not rejected and all(_probe_quiet(o) for o in usable)
note = ""
if measured_can_disable:
# 判据②: 短提示词下"看起来关了"必须过长上下文这一关
long_ctx = await _probe_effort(
model,
Effort.NONE,
rounds=_TIER_LONG_ROUNDS,
prompt=_TIER_LONG_PROMPT,
prompt_kind="long",
)
observations += long_ctx
usable = [o for o in long_ctx if _probe_ok(o)]
if not usable:
note = ";长上下文复核未跑通,结论只在短提示词下成立"
else:
measured_can_disable = all(_probe_quiet(o) for o in usable)
note = ";长上下文复核" + ("同样未观测到推理" if measured_can_disable else "露馅")
if measured_can_disable and not any(
o["thinking_observation"] is ThinkingObservation.ABSENT for o in observations
):
# 判据④: 全程 `UNKNOWN` 时,"关掉了"是一句没有正面证据的话
anchor, anchor_tier, separable = await _anchor_off_against_on(model, observations)
observations += anchor
if anchor_tier is None:
note += ";锚点未跑通,关闭结论缺正面证据"
elif separable:
note += f";锚点可分(关闭档 completion 严格小于 {anchor_tier.value} 档)"
else:
measured_can_disable = None
note += f";**锚点不可分**(与 {anchor_tier.value} 档的 completion 分不开),判不出来"
detail = f"实测 can_disable={measured_can_disable}{note}。短: {_rt_summary(short)}" + (
f" ‖ 后续: {_rt_summary(observations[len(short) :])}"
if len(observations) > len(short)
else ""
)
if measured_can_disable is None:
_probe_record(model, "none 方向", "INCONCLUSIVE(无正面证据)", detail, observations)
pytest.skip(f"{model} 判不出来,已记为未覆盖: {detail[:160]}")
capability = get_capability(model)
if capability is None:
_probe_record(model, "none 方向", "DATA(未登记)", detail, observations)
pytest.skip(f"{model} 未登记(设计 §8 第三档),本条只采数据: {detail[:120]}")
agrees = measured_can_disable == capability.can_disable
_probe_record(
model,
"none 方向",
"PASS" if agrees else "FAIL(能力表已漂移)",
f"声明 can_disable={capability.can_disable};{detail}",
observations,
)
assert agrees, (
f"{model} 的能力表与实测不符: 声明 can_disable={capability.can_disable},"
f"实测 {measured_can_disable}{detail}"
)
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
async def test_t10_declared_tiers_actually_reason(self, model):
"""已登记的每个**开启档**都必须被上游接受,且真的推理。
证伪力只在"被拒""没推理"两件事上**不断言档位之间的 rt 高低**:
设计 §4.3 已定,同一档 rt 实测在 8~56 之间跳,拿它比大小必然是噪声
故本条能证伪的是"登记了一个上游根本不认的档",不是"档位排序对不对"
"""
capability = get_capability(model)
tiers = [e for e in capability.supported_efforts if e is not Effort.NONE]
if not tiers:
pytest.skip(f"{model} 只登记了 none,没有开启档可验")
failures = []
for tier in tiers:
observations = await _probe_effort(
model, tier, rounds=_TIER_ROUNDS, prompt=_TIER_PROMPT, prompt_kind="short"
)
rejected = [
o for o in observations if o["error"] and o["error"].startswith("RequestRejected")
]
usable = [o for o in observations if _probe_ok(o)]
observed = [o for o in observations if _probe_observed(o)]
strangers = _identity_mismatch(model, observations)
if strangers:
# 与 none 方向同一条纪律: 回报的不是这个模型,这组数就不是它的
_probe_record(
model,
f"档位 {tier.value}",
"SKIP(身份不符,数据不可信)",
f"该渠道把请求回报成 {strangers};{_rt_summary(observations)}",
observations,
)
pytest.skip(f"{model} 被该渠道路由到 {strangers},本次观测说的不是这个模型")
if rejected:
verdict, problem = "FAIL(上游拒绝该档)", f"{tier.value}: 上游拒绝"
elif not usable:
verdict, problem = "SKIP(源不可用)", None
elif len(observed) * 2 > len(usable):
verdict, problem = "PASS", None
else:
verdict, problem = "FAIL(该档未推理)", f"{tier.value}: 多数轮未观测到推理"
if problem:
failures.append(problem)
_probe_record(
model, f"档位 {tier.value}", verdict, _rt_summary(observations), observations
)
assert not failures, f"{model} 登记的档位与实测不符: {failures}"
@pytest.mark.parametrize("model", ["gemini-3.1-pro", "gpt-5.5", "glm-5.3"])
async def test_t10_no_opinion_stays_no_opinion(self, model):
"""不表态时库**不推定**模型自己的默认档(Phase 1),顺带采下默认档的 rt 基线。
为什么给这三个模型单列一条: 它们的厂商默认档 evidence 里写着却最容易
写错的一格(Gemini 3.1 Pro 官方文档说 HIGHOpenRouter medium,两源打架),
而默认档写错会误导下游估成本库本身不依赖这个值**它不表态就什么都不注入**,
这正是本条断言的东西;默认档的 rt 观测只作报告里的旁证,**不作断言**: 单一模型上
rt 与档位没有可判定的函数关系(设计 §4.3),拿它反推默认档只能存疑,不能定论
2026-09-05: gemini 一路当下在本渠道上游报错,claude 一路 7 天限额用尽,故把
另两格换成当下可测的 gpt-5.5 glm-5.3;gemini 留着,渠道恢复即有数
"""
client = GatewayClient.from_settings(
_tier_settings(model), capabilities=_probe_capabilities(model)
)
observations = []
try:
for i in range(_TIER_ROUNDS):
try:
resp = await client.chat(
[{"role": "user", "content": _TIER_PROMPT}],
stream=True,
cache_salt=f"tier-default-{model}-{i}",
)
except (
RequestRejectedError,
GatewayUnavailableError,
SourceDeadError,
TransientError,
) as exc:
observations.append(
{
"round": i + 1,
"effort": "(不表态)",
"prompt_kind": "short",
"error": f"{type(exc).__name__}: {str(exc)[:160]}",
}
)
continue
observations.append(
{
"round": i + 1,
"effort": "(不表态)",
"prompt_kind": "short",
"error": None,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens,
"thinking_chars": len(resp.thinking),
"thinking_observation": resp.thinking_observation,
"applied_effort": resp.applied_effort,
"model_reported": resp.model_reported,
"content": resp.content[:40],
}
)
finally:
await client.aclose()
usable = [o for o in observations if _probe_ok(o)]
if not usable:
_probe_record(
model,
"默认档基线(不表态)",
"SKIP(源不可用)",
_rt_summary(observations),
observations,
)
pytest.skip(f"{model} 源不可用,已记为未覆盖: {observations[0]['error'][:120]}")
leaked = [o for o in usable if o["applied_effort"] is not None]
_probe_record(
model,
"默认档基线(不表态)",
"PASS" if not leaked else "FAIL(库替模型推定了默认档)",
_rt_summary(observations),
observations,
)
assert not leaked, f"{model}: 不表态时 applied_effort 应为 None,实测 {leaked}"
+248
View File
@@ -0,0 +1,248 @@
"""PG 集成测试的一次性沙箱工厂(issue #18)。
**为什么把它收敛成一份**: 在此之前,"建临时 schema → 挂 search_path → teardown
删净"这套样板在两个测试文件里重复了七处,清理逻辑各写各的——任何一处写漏,残留都
落在与真实批跑共用的那个库上工厂让清理只有一份实现,并让"用例拿不到管理连接"
成为结构事实而不是纪律
**admin DSN 不做成 fixture**: 它能对共享表执行任何语句做成 fixture 等于把这个
能力摆在每一条用例面前,"用例不该直接用"就只是一句提醒故它是模块私有函数,
只被工厂内部调用,`PgSandbox` 也不携带它
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from uuid import uuid4
import pytest
from dotenv import dotenv_values
if TYPE_CHECKING:
from collections.abc import Sequence
# 测试专用口令: 这些角色只在单条用例的生命周期内存在,且只对自建 schema 有权。
# 它不是机密,写死在这里比走 .env 更清楚——.env 里的每一项都该是真实部署会用的。
_SANDBOX_PASSWORD = "pgw-sandbox-not-a-secret" # noqa: S105
_Role = Literal["none", "owner", "grantee"]
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str
"""已挂 `options=-csearch_path=<schema>`,用例默认用它。"""
bare_dsn: str | None
"""同角色但**不挂** search_path(回落 `"$user", public`);`role="none"` 时为 None。"""
def _admin_dsn() -> str | None:
"""读 `.env` 的 `PGW_TELEMETRY_PG_DSN` 并剥掉 SQLAlchemy 风格的 `+driver` 后缀。"""
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _require_admin_dsn() -> str:
"""取管理连接串;未配置则 skip,连错库则 fail(不是 skip)。
库名守卫不肯降级成 skip: 这个实例上还有 app/chs_prod 等在用库,"连错库"
悄悄跳过,等于让一次配置事故以"没跑那些测试"的形态过关
"""
value = _admin_dsn()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"PG 集成测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
def _with_search_path(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _as_role(dsn: str, role: str) -> str:
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
.pgpass / trust) `postgresql:///db?host=/var/run/postgresql`(unix socket)
都是合法 DSN,却没有可替换的内联凭据段静默返回原串的后果不是测试报错,而是
沙箱以**管理身份**建成用例照常绿,同时 `bare_dsn` 变成超级用户连接最坏
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红
这正是 P5"严禁默认值掩盖错误"要挡的形态
"""
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
if count != 1:
raise RuntimeError(
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
)
return swapped
@pytest.fixture
async def pg_catalog_probe():
"""只读地查 PG catalog,**仅供工厂自测核对残留**,不是通用查询入口。
它拿的是管理连接,故有意只暴露给 `test_pg_sandbox.py` 这一类"验证隔离本身
是否成立"的用例;业务断言一律走 `PgSandbox.dsn`。
"""
import asyncpg
dsn = _require_admin_dsn()
async def probe(sql: str, *args: object) -> list[tuple]:
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
# 管理能力"这句话降格成一句 docstring 里的请求。
if not sql.lstrip().upper().startswith("SELECT"):
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
conn = await asyncpg.connect(dsn, timeout=10)
try:
return [tuple(r) for r in await conn.fetch(sql, *args)]
finally:
await conn.close()
return probe
@pytest.fixture
async def pg_sandbox():
"""一次性沙箱工厂: `await pg_sandbox(ddl=..., role=...)`,清理由 fixture 兜底。
同一条用例可以要多个沙箱("A 的角色去动 B 的表"),它们按后进先出清理
"""
import asyncpg
admin_dsn = _require_admin_dsn()
# 清理动作栈: 每建成一个对象就入栈一条,setup 中途失败与正常 teardown 共用
# 同一条退栈路径——两处各写一份的话,失败那条永远是没被测过的那份。
cleanups: list[str] = []
async def _run_as_admin(*statements: str) -> None:
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
for statement in statements:
await conn.execute(statement)
finally:
await conn.close()
async def _unwind(statements: list[str]) -> None:
"""逆序执行清理并**逐条容错**: 一条失败不该拖累其余对象的清理。
吞掉异常是不行的(残留会静默累积),但让第一条失败中断整栈更糟角色是
全局对象,漏掉的每一个都要人手工去删故全部试完再抛出第一个异常
"""
first: BaseException | None = None
for statement in reversed(statements):
try:
await _run_as_admin(statement)
except Exception as exc: # noqa: BLE001 — 见 docstring: 收集而非吞没
first = first or exc
statements.clear()
if first is not None:
raise first
async def make(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: _Role = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox:
# 权限门在建任何对象**之前**: pytest.skip 抛的是 BaseException,若它在
# 已建对象之后触发,清理会去 DROP 从未建成的东西并把 skip 盖掉。
if role != "none":
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
can_create = await conn.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
)
finally:
await conn.close()
if not can_create:
pytest.skip("当前账号无权建临时角色,跳过需要独占角色的用例")
# schema 与角色的前缀有意不同: 同名会让 "$user" 命中自有 schema 并遮蔽
# 共享表,于是"search_path 落到共享表"这个最坏情况就再也构造不出来。
suffix = uuid4().hex[:12]
schema = f"pgw_s_{suffix}"
role_name = f"pgw_r_{suffix}" if role != "none" else None
# 本次调用自己的清理栈: 失败只回滚**本次**建成的对象。同一条用例常要两个
# 沙箱(如"A 的角色去动 B 的表"),回滚整栈会把已通过断言依赖的对象也删掉。
local: list[str] = []
try:
if role_name is not None:
await _run_as_admin(f"CREATE ROLE {role_name} LOGIN PASSWORD '{_SANDBOX_PASSWORD}'")
# DROP OWNED BY 必须排在 DROP ROLE 之前: 角色仍持有对象时删不掉
local.append(f"DROP ROLE IF EXISTS {role_name}")
local.append(f"DROP OWNED BY {role_name}")
owner_clause = f" AUTHORIZATION {role_name}" if role == "owner" else ""
await _run_as_admin(f"CREATE SCHEMA {schema}{owner_clause}")
local.append(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
bare = _as_role(admin_dsn, role_name) if role_name is not None else None
# role="owner" 时 DDL 由角色自己执行,表属主才会是它;"grantee" 的现场
# 恰恰相反——表由别的账号建好,角色只拿到表级权限。
ddl_dsn = _with_search_path(bare if role == "owner" else admin_dsn, schema)
if ddl is not None:
conn = await asyncpg.connect(ddl_dsn, timeout=10)
try:
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
if role == "grantee":
await _run_as_admin(f"GRANT USAGE ON SCHEMA {schema} TO {role_name}")
if ddl is not None:
await _run_as_admin(
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
)
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
used = bare if role_name is not None else admin_dsn
sandbox = PgSandbox(
schema=schema,
role=role_name,
dsn=_with_search_path(used, schema),
bare_dsn=bare,
)
if role_name is not None:
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
# IF EXISTS,第二遍必报错)。
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
actual = await conn.fetchval("SELECT current_user")
finally:
await conn.close()
if actual != role_name:
raise RuntimeError(
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
"权限边界不成立,拒绝把这个沙箱交出去。"
)
except BaseException:
await _unwind(local)
raise
cleanups.extend(local)
return sandbox
yield make
await _unwind(cleanups)
+45 -6
View File
@@ -11,7 +11,12 @@ import sqlite3
import httpx
import pytest
from polygateway import CircuitOpenError, GatewayClient, TransientError
from polygateway import (
CircuitOpenError,
GatewayClient,
RequestRejectedError,
TransientError,
)
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter
@@ -114,7 +119,7 @@ class TestBreakerRecoveryFullChain:
class TestCancellationThroughStack:
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
entered = asyncio.Event()
async def hanging_handler(request):
@@ -139,7 +144,7 @@ class TestCancellationThroughStack:
class TestTelemetryAcrossPaths:
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
@@ -150,7 +155,7 @@ class TestTelemetryAcrossPaths:
assert hits == 1 and total == 2
async def test_transient_attempts_each_recorded(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
calls = {"n": 0}
def flaky(request):
@@ -173,6 +178,40 @@ class TestTelemetryAcrossPaths:
assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立
class TestRejectionReasonIsQueryable:
"""issue #10 的验收主张: 400 之后,网关说的话必须能在遥测表里查到。
下游一轮 1050 张影像的批处理里,1 张在读表格时收到 400 被判确定性失败,
事后"这张图到底哪里不合规"无从查起响应体在 transport 翻译层就没了
"""
# issue #10 原文给出的真实响应体(一字不改)
_BODY = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client(
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
)
with pytest.raises(RequestRejectedError):
await client.chat([{"role": "user", "content": "hi"}])
recorder.close()
rows = sqlite3.connect(tmp_path / "t.db").execute("SELECT error FROM llm_calls").fetchall()
assert rows, "400 必须留下遥测行(遥测必录)"
errors = " ".join(r[0] or "" for r in rows)
# 修复前这里只有 "qwen_1 请求被拒: 400"——诊断信息一个字都不在
assert "InvalidParameter" in errors
assert "The image format is illegal" in errors
# 尾部的 code 才是向网关方追查的凭据,头部硬切正好会丢掉它
assert "invalid_parameter_error" in errors
class TestStructuredThroughStack:
async def test_feedback_reask_passes_through_governance(self):
"""重问经过内层治理: 第二次真实请求同样被限流/熔断记账。"""
@@ -218,7 +257,7 @@ class TestSamplingThroughStack:
return _sse()
db = tmp_path / "t.db"
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
client = _full_client(handler, telemetry=recorder)
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
recorder.close()
@@ -237,7 +276,7 @@ class TestSamplingThroughStack:
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
db = tmp_path / "t.db"
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
client = GatewayClient(
scope="llm",
sources=[src],
+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))
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,7 @@ class ScriptedTransport:
self.hang = hang
self.calls: list[str] = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append(source.name)
if self.hang:
await asyncio.Event().wait()
@@ -327,3 +327,49 @@ async def test_variant_probe_rate_limited_releases_not_hangs(redis_client):
assert update.applied
nxt = await gate.try_enter("s1", "w2")
assert nxt.allowed and nxt.is_probe # 立即可再探,不等 probe_ttl
# —— issue #14: retry_after_s = 距离**确定**可再试的时刻,HALF_OPEN 无确定时刻 ——
@pytestmark_slow
async def test_variant_half_open_rejection_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.is_probe
blocked = await gate.try_enter("s1", "w2")
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
assert blocked.retry_after_s == 0.0
@pytestmark_slow
async def test_variant_probe_grant_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.allowed and probe.is_probe
assert probe.retry_after_s == 0.0
@pytestmark_slow
async def test_variant_retry_after_zero_while_probe_in_flight(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe
assert await gate.retry_after_s(("s1",)) == 0.0
@pytestmark_slow
async def test_variant_fenced_write_in_half_open_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
await _open_gate(gate) # 他人开路,epoch 推进
await asyncio.sleep(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
update = await gate.record_success(stale)
assert not update.applied and update.state is GateState.HALF_OPEN
assert update.retry_after_s == 0.0
+401
View File
@@ -0,0 +1,401 @@
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
与真实批跑共用**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到
这条纪律取代了此前那条"跑完对比共享表行数"的安全网行数快照守的是安全属性,却把它
编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴
权限边界两个方向都没有
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from polygateway.telemetry.schema import PG_DDL
_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2, $3)"
)
def _partitioned_ddl() -> str:
"""由库的真实 `PG_DDL` 派生一份 RANGE 分区版建表语句。
不另抄一份 DDL: 抄的那份与库的 schema 必然漂移,而漂移后本测试验的就不再是
"库建的表被做成分区后脚本认不认得"两处改动都是分区表的**硬性要求**
分区表上的唯一约束必须包含分区键, `call_id` 单列主键不再合法
"""
body, count = re.subn(
r"call_id(\s+)TEXT PRIMARY KEY", r"call_id\1TEXT NOT NULL", PG_DDL, count=1
)
if count != 1:
raise AssertionError("PG_DDL 的 call_id 主键声明形态已变,分区版 DDL 需同步")
body = body.strip().rstrip(";").strip()
if not body.endswith(")"):
raise AssertionError("PG_DDL 结尾形态已变,分区版 DDL 需同步")
return (
f"{body[:-1].rstrip()},\n"
" PRIMARY KEY (call_id, created_at)\n"
") PARTITION BY RANGE (created_at)"
)
def _stamp(delta: timedelta) -> datetime:
return datetime.now(UTC) + delta
def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(_SCRIPT), *args],
capture_output=True,
text=True,
cwd=_ROOT,
env=env,
timeout=120,
)
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
import asyncpg
conn = await asyncpg.connect(schema_dsn, timeout=10)
try:
await conn.executemany(_INSERT, rows)
finally:
await conn.close()
async def _call_ids(schema_dsn: str) -> list[str]:
import asyncpg
conn = await asyncpg.connect(schema_dsn, timeout=10)
try:
rows = await conn.fetch("SELECT call_id FROM llm_calls ORDER BY call_id")
finally:
await conn.close()
return [r["call_id"] for r in rows]
@pytest.fixture
async def partitioned_sandbox(pg_sandbox):
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
return await pg_sandbox(
ddl=_partitioned_ddl(),
extra=(
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
),
role="owner",
)
@pytest.fixture
async def plain_sandbox(pg_sandbox):
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
return await pg_sandbox(ddl=PG_DDL, role="owner")
class TestPartitionedTarget:
async def test_partitioned_table_exits_three_without_deleting_anything(
self, partitioned_sandbox
):
await _seed(
partitioned_sandbox.dsn,
[
("part-old-1", "", _stamp(timedelta(days=-30))),
("part-old-2", "acme", _stamp(timedelta(days=-20))),
],
)
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
result = _run(
"--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout
class TestPlainTableBatches:
async def test_apply_deletes_only_expired_rows_in_batches(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",
)
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
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
assert "批次 1" in result.stdout
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "将删除行数: 1" in result.stdout
assert "dry-run" in result.stdout
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestExplicitTable:
"""`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。
单测只能验参数分类,验不了 `to_regclass` 的语义schema 不存在返 NULL 而非抛错
引号限定名区分大小写无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立
"""
async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
await _seed(
plain_sandbox.dsn,
[
("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))),
("old-3", "acme", _stamp(timedelta(days=-20))),
("old-4", "acme", _stamp(timedelta(days=-15))),
("old-5", "", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--batch-size",
"2",
"--table",
f"{plain_sandbox.schema}.llm_calls",
)
# 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout
assert "批次 1" in result.stdout
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
"""schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用""""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
missing = "pgw_s_nosuchxxxxxxxx"
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{missing}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert missing in result.stderr
assert "llm_calls" in result.stderr
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
"""拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
sandbox_a.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{sandbox_b.schema}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert await _call_ids(sandbox_a.dsn) == ["a-old"]
assert await _call_ids(sandbox_b.dsn) == ["b-old"]
async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{partitioned_sandbox.schema}.llm_calls",
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]
class TestInferredTargetHint:
async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
"""未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。
该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 0, (result.stdout, result.stderr)
assert "search_path" in result.stdout
assert "--table" in result.stdout
async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
"""dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "--table" not in result.stdout
class TestSearchPathFallsThrough:
async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
"""最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。
用沙箱角色的**** DSN (search_path 回落 `"$user", public`,而角色名与 schema
名有意错开, `"$user"` 命不中沙箱),不给 `--table`, `--apply`角色对共享表
无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
没有则解析不到**不断言 PG 的英文原文**服务端 `lc_messages` 不由测试掌握
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.bare_dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert result.stderr.strip()
assert "llm_calls" in result.stderr
# 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestMissingAsyncpg:
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
场景: 脚本跑在子进程里,monkeypatch 对它无效DSN **真实可连**的临时 schema,
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
stub = tmp_path / "stub"
stub.mkdir()
(stub / "asyncpg.py").write_text(
'raise ImportError("asyncpg 未安装(测试构造)")\n', encoding="utf-8"
)
env = {
**os.environ,
"PYTHONPATH": os.pathsep.join(
[str(stub), *([p] if (p := os.environ.get("PYTHONPATH")) else [])]
),
}
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
env=env,
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert "asyncpg" in result.stderr
assert "pip install" in result.stderr
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
+205 -7
View File
@@ -13,8 +13,10 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import (
AllSourcesExhausted,
CircuitOpenError,
GatewayUnavailableError,
GovernanceBackendError,
SourceDeadError,
SourceNotConfiguredError,
TransientError,
)
@@ -62,6 +64,7 @@ def _mw(
sleep,
rng=lambda: 0.0,
quota_full="wait",
circuit_open="fail_fast",
gate=None,
transport=None,
emitter=None,
@@ -76,6 +79,7 @@ def _mw(
retry=RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0),
backpressure=BackpressurePolicy(stall_window_s=_STALL, poll_interval_s=0.01),
quota_full=quota_full,
circuit_open=circuit_open,
cooldown_memo=SourceCooldownMemo(now=clock),
emitter=emitter,
now=clock,
@@ -206,7 +210,7 @@ class ClockAdvancingTransport:
self.clock = clock
self.calls = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
advance, action = self.script.pop(0)
self.clock.advance(advance)
@@ -321,9 +325,7 @@ class TestStallBudget:
async def advance(_n):
clock.advance(_STALL)
mw = _mw(
[src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport
)
mw = _mw([src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "stalled" # 不是 retry_exhausted: 429 确实没烧重试预算
@@ -541,9 +543,7 @@ class TestUnknownSourceIsAssemblyDefect:
"""
src = make_source("s1")
# 限流后端的源名单与治理循环拿到的源对不上 = 装配缺陷
limiter = InMemoryLimiter(
scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL
)
limiter = InMemoryLimiter(scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL)
gate = QuotaGate(limiter, scope="llm")
with pytest.raises(SourceNotConfiguredError) as ei:
await getattr(gate, method)(src)
@@ -597,3 +597,201 @@ class TestGateFailuresReachCallersAsScopeLevel:
await QuotaGate(_Broken(), scope="LLM").progress_age_s()
assert ei.value.scope == "llm"
assert ei.value.reason == "governance_backend_down"
class TestCircuitOpenPolicy:
"""issue #14: 熔断全拒时是当场判死还是等冷却过去。
缺省 fail_fast 即历史行为(TestStallQuadrants 等既有用例照旧覆盖);
本类钉的是 wait ,以及两条策略互不串线
"""
@staticmethod
async def _opened_gate(clock, cfg=_BREAKER):
gate = InMemoryGate(config=cfg, now=clock)
for _ in range(cfg.fail_threshold):
entry = await gate.try_enter("s1", "w")
await gate.record_failure(entry, "network_error", False)
return gate
@staticmethod
def _free_limiter(clock, src):
return InMemoryLimiter(
scope="llm",
sources={"s1": src},
global_limits=_NO_GLOBAL,
lease_ttl_s=10_000.0,
now=clock,
)
async def test_fail_fast_is_the_default(self):
"""缺省档逐字保持历史行为: 全源开路当场抛 CircuitOpenError。"""
clock = FakeClock()
src = make_source()
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=BoundedSleep(),
gate=await self._opened_gate(clock),
)
with pytest.raises(CircuitOpenError) as ei:
await mw(_REQ)
assert ei.value.reason == "circuit_open"
async def test_wait_sleeps_out_the_cooldown_instead_of_dying(self):
"""wait 档: 睡到冷却结束再来一轮,拿到探针后正常返回。
睡的是**冷却剩余**而不是 poll_interval60 秒冷却用 10ms 轮询要空转
6000 ,memory 后端只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用
"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[_ok()],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock),
circuit_open="wait",
)
resp = await mw(_REQ)
assert resp.content == "ok"
# 一觉睡到冷却结束(jitter 上加,rng=0 → +0.5×poll),不是 poll 空转
assert sleep.delays[0] == pytest.approx(_BREAKER.cooldown_s + 0.005)
async def test_wait_does_not_leak_into_the_quota_branch(self):
"""两条策略互不串线: circuit_open=wait 配 quota_full=fail_fast 时,
熔断等待**不得**被当成配额耗尽上报串线会让调用方拿到一个
reason=quota_exhausted 的异常,而配额其实是满的"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[_ok()],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock),
quota_full="fail_fast",
circuit_open="wait",
)
assert (await mw(_REQ)).content == "ok"
async def test_wait_still_dies_when_cooldown_outlasts_the_stall_budget(self):
"""等待有可解释的上界: 冷却比 stall 预算还长时,在窗口耗尽处判死。
单次睡眠夹到剩余 stall 预算,故最坏墙钟 = stall_window + 一个 poll,
不随 max_cooldown_s 漂移
"""
clock = FakeClock()
src = make_source()
long_cooldown = BreakerConfig(
fail_threshold=3, cooldown_s=1000.0, probe_ttl_s=2000.0, max_cooldown_s=1000.0
)
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock, long_cooldown),
circuit_open="wait",
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "stalled"
assert ei.value.per_source_reasons == {"s1": "circuit_open"}
assert sleep.delays[0] == pytest.approx(_STALL + 0.01) # 夹到预算 + 一个 poll
async def test_wait_loop_stays_cancellable(self):
"""取消穿透(铁律): 熔断等待中的取消不得被吞。"""
clock = FakeClock()
src = make_source()
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=asyncio.sleep,
gate=await self._opened_gate(clock),
circuit_open="wait",
)
task = asyncio.create_task(mw(_REQ))
await asyncio.sleep(0.03)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_wait_does_not_exempt_probes_from_the_retry_budget(self):
"""wait 档不豁免重试预算: 探针是**真实尝试**,失败照样烧 max_attempts。
force_open 的源(401/403/欠费一击即熔,不看任何阈值) wait 档下并
**不是**"等满 stall 窗口才死"两个预算哪个先耗尽就以哪个的 reason
失败这里 max_attempts=3 而冷却只累计 120s < stall_window=300s,
先到的是重试预算参数换成"冷却累计超过 stall 预算"则先到 stalled
( test_wait_still_dies_when_cooldown_outlasts_the_stall_budget)
这与 issue #8 确立的划分一致: 划分依据是"谁消耗重试预算",探针发出了
真实请求,理应记在重试预算上而不是 stall 账上
"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[SourceDeadError("401"), SourceDeadError("401"), SourceDeadError("401")],
clock=clock,
sleep=sleep,
circuit_open="wait",
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "retry_exhausted"
assert clock.t - 1000.0 < _STALL # 远未等满 stall 窗口
async def test_half_open_rejection_does_not_blacklist_a_recovered_source(self):
"""issue #14 §1.3 回归: 探针成功后本进程立即可再选该源。
此前 HALF_OPEN 拒绝把探针租约(派生自 2 × timeout,现场 600s)写进冷却
备忘, `set_until` 取更晚者不可回退门恢复 CLOSED 之后本进程仍
跳过该源整整一个租约,单源下每次调用照旧判死多源部署同样中招,只是
被别的源接住流量掩盖了
"""
clock = FakeClock()
cfg = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=600.0)
gate = await self._opened_gate(clock, cfg)
memo = SourceCooldownMemo(now=clock)
clock.advance(cfg.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
blocked = await gate.try_enter("s1", "w2") # 并发调用撞上在途探针
assert not blocked.allowed
memo.set_until("s1", clock() + blocked.retry_after_s) # 准入路径的写法
await gate.record_success(probe) # 探针成功 → 门恢复 CLOSED
assert not memo.active("s1")
+266 -1
View File
@@ -5,11 +5,19 @@ import hashlib
import json
import pytest
from loguru import logger
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.errors import ResultInvalidError, TransientError
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
from polygateway.types import ChatRequest, LLMResponse
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
SourceConfig,
ThinkingObservation,
)
_MSGS = [{"role": "user", "content": "hi"}]
@@ -112,6 +120,62 @@ class TestKeyFormula:
"m", messages2, "p", None
)
def test_request_tier_changes_key(self):
"""同 messages 跑 low 与 max 不得互相命中(issue #20;issue #4 的逐字翻版)。
请求级档位必须**独立于** `model_fingerprint` key: 后者是装配期算出的
集合级指纹,一次调用改档位不会让它变一个字节
"""
k_low = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.LOW)
k_max = build_cache_key("m", _MSGS, "proj", None, reasoning_effort=Effort.MAX)
assert k_low != k_max
def test_explicit_none_tier_is_not_the_absent_tier(self):
"""`None`(不表态)与 `Effort.NONE`(要求不推理)是两个 key。
二者合并即毒化: "没写档位"的调用会读到"明确关掉推理"那次的响应,
而后者的内容恰恰是缺推理过程的
"""
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
"m", _MSGS, "proj", None, reasoning_effort=Effort.NONE
)
def test_absent_tier_keeps_legacy_key(self):
"""不表态档位时键形逐字不变,存量缓存不被本次升级全量作废。
golden 值与 `test_empty_sampling_keeps_legacy_key` 同源,取自加
`reasoning_effort` 维度之前的实现,不得随实现漂移
"""
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
None,
reasoning_effort=None,
) == ("pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b")
def test_declared_tier_key_is_a_golden(self):
"""配了档位那一侧同样要有 golden: 字面量变了就是所有该档缓存冷启动。
存量(不表态)那侧的 golden `test_absent_tier_keeps_legacy_key` 守着,
"档位怎么写进 key"此前没有任何字面量断言变异实测把 `str(...)` 换成
`repr(...)`,全套件依然全绿(2026-09-05 独立验证查出)
"""
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
None,
reasoning_effort=Effort.LOW,
) == ("pgw:cache:21d7be93729635b27d4ee54e0e7e7310554bb04faf08919f8ce83794ac33f575")
assert build_cache_key(
"qwen-max",
[{"role": "user", "content": "hi"}],
"proj",
"s1",
reasoning_effort=Effort.NONE,
) == ("pgw:cache:44f4f1ce1ee39e5003a27f4f21a531554cb54d1c66e936d11008d4ff06ddc5e6")
class _Terminal:
def __init__(self, response):
@@ -161,6 +225,21 @@ class TestCacheFlow:
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
assert third.cache_hit is True and terminal.calls == 2
async def test_differing_reasoning_effort_does_not_hit(self):
"""接线门: `CacheMW` 必须把 `request.reasoning_effort` 传进 key 公式。
只测 `build_cache_key` 不够参数加了却没人传是本改动最可能的落地方式,
那种缺口在公式层的用例里完全看不见
"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp())
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.MAX), terminal)
assert terminal.calls == 2 # 两档各自回源
third = await mw(ChatRequest(messages=_MSGS, reasoning_effort=Effort.LOW), terminal)
assert third.cache_hit is True and terminal.calls == 2 # 同档才命中
async def test_structured_injection_does_not_pollute_key(self):
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
backend = InMemoryCache()
@@ -248,6 +327,143 @@ class TestObservabilityFieldsOnHit:
assert hit.cached_prompt_tokens is None and hit.model_reported is None
class TestThinkingObservationRehydration:
"""issue #16/#17: 命中回放必须复活成枚举实例,而不是 JSON 里的裸 str。
str 与字段注解分叉,下游拿 `resp.thinking_observation is
ThinkingObservation.OBSERVED` 判等会在缓存命中路径上静默为 False
"""
async def test_hit_replays_enum_instance_not_bare_str(self):
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(thinking_observation=ThinkingObservation.OBSERVED))
await mw(ChatRequest(messages=_MSGS), terminal)
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and terminal.calls == 1
assert isinstance(hit.thinking_observation, ThinkingObservation)
assert hit.thinking_observation is ThinkingObservation.OBSERVED
async def test_unknown_value_degrades_to_unknown_and_still_hits(self):
"""域外取值降级为 UNKNOWN,内容照常复活——不得因此作废整条缓存。
真实场景: 三项目共用一个 Redis,先升级的项目写入了本版没有的第四态,
未升级的两个项目若把它判成未命中,就会在这些 key 上每次真打网关随后
覆写回旧值,两个版本互相打对方的缓存(表现是命中率莫名腰斩)一个纯
可观测性字段不该有能力废掉内容完好的缓存响应
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
poisoned["thinking_observation"] = "partially_observed"
poisoned.pop("structured_data", None)
await backend.set(key, json.dumps(poisoned), 3600)
terminal = _Terminal(_resp())
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
resp = await mw(ChatRequest(messages=_MSGS), terminal)
finally:
logger.remove(sink_id)
assert terminal.calls == 0 and resp.cache_hit is True
assert resp.content == "from-a-newer-version" # 内容完好,照常复活
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
# 单独一条讲清原因的 warning: 通用的"重建失败"没有任何线索指向真因
hits = [m for m in messages if "partially_observed" in m]
assert len(hits) == 1, f"域外取值必须单独告警一次,实得 {len(hits)} 条: {messages}"
assert "thinking_observation" in hits[0]
assert [m for m in messages if "重建失败" in m] == []
async def test_a_broken_payload_still_falls_back_to_source(self):
"""对照组: 内容完整性真被破坏时,仍必须按未命中回源(降级方向不变)。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
await backend.set(key, "{not json at all", 3600)
terminal = _Terminal(_resp())
resp = await mw(ChatRequest(messages=_MSGS), terminal)
assert terminal.calls == 1 and resp.cache_hit is False
assert resp.content == "cached"
async def test_legacy_entry_without_key_rehydrates_to_default(self):
"""升级前写入的条目没有该键,必须照常复活并落到默认 UNKNOWN。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
legacy = dataclasses.asdict(_resp(content="legacy"))
legacy.pop("thinking_observation")
legacy.pop("structured_data", None)
await backend.set(key, json.dumps(legacy), 3600)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0
assert hit.thinking_observation is ThinkingObservation.UNKNOWN
class TestAppliedEffortRehydration:
"""issue #20: 实际档同样必须复活成枚举,理由与 `thinking_observation` 逐条相同。
JSON 里存的是 `StrEnum` 的字符串值;不转就复活成裸 str,而库内一路是
`is Effort.LOW` 的身份比较命中路径上会静默判否,且下游拿到的类型与字段
注解分叉缓存是档位的**第三条入口**(另两条是 `.env` 解析与 `chat()` 参数),
归一化不变式必须在这里也闭合
"""
async def test_hit_replays_enum_instance_not_bare_str(self):
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(applied_effort=Effort.LOW))
await mw(ChatRequest(messages=_MSGS), terminal)
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and terminal.calls == 1
assert isinstance(hit.applied_effort, Effort)
assert hit.applied_effort is Effort.LOW
async def test_unknown_tier_degrades_to_none_and_still_hits(self):
"""域外档位降级为 None(=不知道这次跑在哪档),不作废内容完好的条目。
降级方向与 `thinking_observation` 同源: 共用一个 Redis 的项目里,先升级
的那个可能写入本版没有的档位名,未升级的项目若判成未命中,两个版本就会
互相打对方的缓存归因字段不该有能力废掉一条内容完好的响应
"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
poisoned = dataclasses.asdict(_resp(content="from-a-newer-version"))
poisoned["applied_effort"] = "ultra"
poisoned.pop("structured_data", None)
await backend.set(key, json.dumps(poisoned), 3600)
terminal = _Terminal(_resp())
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
resp = await mw(ChatRequest(messages=_MSGS), terminal)
finally:
logger.remove(sink_id)
assert terminal.calls == 0 and resp.cache_hit is True
assert resp.content == "from-a-newer-version"
assert resp.applied_effort is None
hits = [m for m in messages if "ultra" in m]
assert len(hits) == 1, f"域外档位必须单独告警一次,实得 {len(hits)} 条: {messages}"
assert "applied_effort" in hits[0]
assert [m for m in messages if "重建失败" in m] == []
async def test_legacy_entry_without_key_rehydrates_to_none(self):
"""升级前写入的条目没有该键,必须照常复活并落到默认 None。"""
backend = InMemoryCache()
mw = _mw(backend)
key = build_cache_key("m", _MSGS, "proj", None)
legacy = dataclasses.asdict(_resp(content="legacy"))
legacy.pop("applied_effort")
legacy.pop("structured_data", None)
await backend.set(key, json.dumps(legacy), 3600)
terminal = _Terminal(_resp())
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.content == "legacy" and terminal.calls == 0
assert hit.applied_effort is None
class _BrokenBackend:
async def get(self, key):
raise ConnectionError("redis down")
@@ -327,3 +543,52 @@ class TestStructuredRehydration:
key = build_cache_key("m", _MSGS, "proj", None)
raw = await backend.get(key)
assert raw is not None and "structured_data" not in json.loads(raw)
class TestTelemetryCapDoesNotPoisonTheCacheKey:
"""红线之一(issue #12): 遥测截断绝不能改到缓存 key。
`digest_messages` content list 的消息**原样透传同一个 dict 对象**
(本文件上方公式测试依赖的也是这份对象),遥测拿到的与算 key 用的是同一份
就地截断会让同一组 messages 在遥测前后算出两个不同的 key全量 miss
且没有任何报错故这里测的是"截断没有就地改掉调用方的对象",不只是
"截断函数是纯的"
"""
class _Rows:
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
async def test_key_is_byte_identical_across_a_capped_emit(self):
messages = [
{"role": "user", "content": "合同正文" * 31},
{"role": "user", "content": [{"type": "text", "text": "标书正文" * 30}]},
]
before = build_cache_key("m", messages, "proj", None)
rec = self._Rows()
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
request=ChatRequest(messages=messages),
source=SourceConfig(
name="s1",
provider="p",
base_url="https://gw.example/v1",
api_key="sk",
model="m",
timeout_s=10.0,
),
call_id="c",
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
# 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"])
assert "(略 116 字)" in logged[0]["content"]
assert "(略 112 字)" in logged[1]["content"][0]["text"]
assert build_cache_key("m", messages, "proj", None) == before
+882
View File
@@ -12,6 +12,7 @@ from polygateway import (
AllSourcesExhausted,
GatewayClient,
GatewaySettings,
RequestRejectedError,
gather_bounded,
)
from polygateway.backends.memory.breaker import InMemoryGate
@@ -24,6 +25,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
@@ -45,6 +47,20 @@ _ENV = {
"PGW_TELEMETRY_BACKEND": "none",
}
_OCR_ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
"OCR__MONKEY__1__API_KEY": "none",
"OCR__MONKEY__1__MODEL": "monkey-ocr",
"OCR__MONKEY__1__TIMEOUT_S": "120",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
def _sse(content='{"answer": 1}'):
chunk = json.dumps({"choices": [{"delta": {"content": content}}]})
@@ -184,6 +200,312 @@ class TestSamplingOverlay:
assert [c["seed"] for c in captured] == [1, 2]
class TestReasoningEffortPriority:
"""三层优先级: 请求级 > 源级 > `enable_thinking` 语法糖 > 不表态(设计 §4.2)。
一律抓**真实请求体**而非只查 `ChatRequest` 字段: 档位的价值全在发出去的那几个
字节上,只断言中间态会让"字段填了但一路没人读"这种缺口继续通过测试issue #20
`extra_body` 绕行正是这么长出来的
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
def _zhipu(self, **overrides):
# glm-5.3 的档位是 low/high/max(能力表已登记),zhipu 的 wire 三样俱全,
# 是唯一能同时看清"开启形态"与"档位键"的组合
overrides.setdefault("model", "glm-5.3")
return _source(provider="zhipu", **overrides)
async def test_request_effort_wins_over_source(self):
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MAX)
assert captured[0]["reasoning_effort"] == "max"
assert captured[0]["thinking"] == {"type": "enabled"}
async def test_none_request_does_not_clear_source(self):
"""请求级不表态 ≠ 请求级要求"不推理": 前者必须让源级默认继续生效。"""
captured = []
source = self._zhipu(reasoning_effort=Effort.LOW)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "low"
async def test_request_none_tier_is_an_opinion_not_an_absence(self):
"""请求级 `none` 是"要求不推理",不得被当成"没表态"而回落到源级档位。"""
captured = []
source = self._zhipu(model="glm-5.2", reasoning_effort=Effort.MAX)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE)
assert captured[0]["thinking"] == {"type": "disabled"}
assert "reasoning_effort" not in captured[0]
@pytest.mark.parametrize(
("provider", "model", "fragment"),
[
("qwen", "qwen-max", {"enable_thinking": True}),
("deepseek", "deepseek-v4-pro", {"thinking": {"type": "enabled"}}),
("zhipu", "glm-5.3", {"thinking": {"type": "enabled"}}),
("moonshot", "kimi-k3", {"thinking": {"type": "enabled"}}),
],
)
async def test_legacy_on_tier_matches_old_fragment(self, provider, model, fragment):
"""存量 `ENABLE_THINKING=true` 的回归门: 发出去的字节逐字不变。
**只覆盖 `on_base` 自己就说全了""的四段**openai/anthropic/google 的开档
旧版硬编码 `{"reasoning_effort": "medium"}`,新版不注入任何档位那是设计
§4.2 声明过的**有意变更**(medium GLM/kimi/deepseek 的档位表里根本不存在,
是库替下游做的档位判断),不是本门要守的不变量;这三家的模型经 OpenRouter
登记均为默认推理,不注入也仍是""minimax 不在此列: 它的模型不满足该前提,
已按 issue #21 改回 medium,由下一条用例单独守。
qwen/deepseek 两条字面量逐字取自升级前的 `ProviderProfile.thinking_on`;
zhipu/moonshot 升级前没有对应段,断言的是它们 2026-09-04 登记的形态
"""
captured = []
source = _source(provider=provider, model=model, enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
body = captured[0]
assert {k: body[k] for k in fragment} == fragment
# `auto` = 开启但不指定强度: 语法糖不得替调用方挑一个档
assert "reasoning_effort" not in body
async def test_legacy_minimax_on_tier_actually_turns_reasoning_on(self):
"""回归门(issue #21): minimax 段的存量 `ENABLE_THINKING=true` 必须真开推理。
本次换代一度把这段的开启形态改成 `on_base={}`(什么参数都不注入),依据是
"这些模型默认就推理,不注入也仍是''"T10 真实网关实测推翻了该前提:
MiniMax-M3 不带任何推理参数时 5/5 **不推理**(六个强度值则全部生效)
于是存量下游从"真开推理"静默变成"不推理", `resolve_thinking` Phase 5
无条件放行 `auto`能力表也堵不住这条路
断言落在**发出去的字节**上而非中间态: 静默不推理这件事只有在请求体里才看得见
"""
captured = []
source = _source(provider="minimax", model="MiniMax-M3", enable_thinking=True)
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}])
assert captured[0]["reasoning_effort"] == "medium"
class TestEffortFallbackWiring:
"""源级 `effort_fallback` 必须真的走到 `resolve_thinking`(issue #20)。
只测配置值域与纯函数是不够的: 变异实测显示把 `fallback=source.effort_fallback`
换成硬编码 `"error"`,全套单测依然全绿`nearest` 是人类明确要求实现的功能,
没有端到端用例它会在零告警下变成死代码(2026-09-05 独立验证查出)
**该形态有两道门,必须各测各的**: transport 那道决定发出去的字节,`_guard_thinking`
那道决定装配能不能过只钉住 transport,装配守卫退化成硬编码 `error` ,配了
`nearest` 的源会在**运行期本来跑得起来**的情况下于装配期当场被判死,而这半边
第一轮修复时正是漏掉的那半边(M22)
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
async def test_nearest_sends_the_mapped_tier(self):
"""glm-5.3 只有 low/high/max: 请求 `medium` 等距,按"取弱"落到 low。"""
captured = []
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM)
assert captured[0]["reasoning_effort"] == "low"
async def test_error_fallback_refuses_the_same_request(self):
"""默认 `error` 必须报错而不是映射: 一次静默的换档就是一笔没打招呼的账单。"""
captured = []
source = _source(provider="zhipu", model="glm-5.3")
async with self._capturing_client(captured, sources=[source]) as client:
with pytest.raises(RequestRejectedError, match="medium"):
await client.chat(
[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
)
assert captured == [] # 请求根本没发出去
def test_nearest_survives_the_assembly_guard(self):
"""装配守卫也必须用源级 `fallback`: 运行期能映射的源不该在装配期被判死。
源级 `medium` + glm-5.3(只有 low/high/max) `nearest` 唯一能被观测到的
装配期形态守卫硬编码 `error` ,这次 `from_env` 会抛
`ThinkingUnsupportedError` 而不是返回 client
"""
env = dict(_ENV)
for key in list(env):
if key.startswith("LLM__QWEN__"):
del env[key]
env.update(
{
"LLM__ZHIPU__1__BASE_URL": "https://gw.example/v1",
"LLM__ZHIPU__1__API_KEY": "sk-a",
"LLM__ZHIPU__1__MODEL": "glm-5.3",
"LLM__ZHIPU__1__TIMEOUT_S": "120",
"LLM__ZHIPU__1__REASONING_EFFORT": "medium",
"LLM__ZHIPU__1__EFFORT_FALLBACK": "nearest",
}
)
client = GatewayClient.from_env("LLM", env=env)
assert isinstance(client, GatewayClient)
class TestAppliedTierReachesTheCaller:
"""`LLMResponse.applied_effort` 报的是**真正发出去的**那一档(计划 T8-5)。
对下游是新能力(它终于能知道这次跑在哪档),对遥测是前置条件: 记请求档会让
按档分组的压测把整行挂在一个从未发出过的档下,而那种数据错得看不出来
"""
async def test_response_carries_the_mapped_tier(self):
"""glm-5.3 无 `medium`: 开了 nearest 后实际跑的是 low,响应必须这么说。"""
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
async with _client(sources=[source]) as client:
resp = await client.chat(
[{"role": "user", "content": "hi"}], reasoning_effort=Effort.MEDIUM
)
assert resp.applied_effort is Effort.LOW
async def test_no_statement_leaves_the_field_none(self):
async with _client() as client:
resp = await client.chat([{"role": "user", "content": "hi"}])
assert resp.applied_effort is None
class TestUnsupportedTierIsRefusedNotRetried:
"""档位不可满足 = 请求本身的问题: 报 `RequestRejectedError`,不重试、不伤熔断。
重试与换源都不会让它变对(设计 §10),而把它计进熔断更糟一次配置错误会
把一个健康的源关掉,拖垮与推理无关的所有调用
"""
async def test_tier_error_never_reaches_the_gateway_or_the_breaker(self):
sent = []
def handler(request):
sent.append(request)
return _sse()
# 阈值取 1: 只要这次失败被计进熔断,门当场开路,断言立刻可见
gate = InMemoryGate(config=BreakerConfig(1, 60.0, 120.0))
source = _source(name="zp", provider="zhipu", model="glm-5.3")
async with _client(sources=[source], handler=handler, breaker=gate) as client:
with pytest.raises(RequestRejectedError, match="无法关闭推理"):
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort=Effort.NONE)
assert sent == [], "请求根本不该发出去: 档位不可满足在组装期就已判定"
assert (await gate.try_enter("zp", "w")).allowed, "配置错误不得计入熔断失败"
class TestRequestTierNormalization:
"""`chat(reasoning_effort=...)` 是公共入口,裸字符串必须在此归一(issue #20)。
两条装配路(工厂 / 构造函数全量注入,CLAUDE.md §4.5) `.env` 路的口径必须
一致后者早已是"解析即归一"不归一的后果不是"少个类型注解"那么轻: 档位
一路要被 `is Effort.NONE` 身份比较,裸字符串会在 transport 的错误路径上抛
`AttributeError`,而它不属错误四分类,会穿透 `except ThinkingUnsupportedError`
RetryMW 的分类捕获,以未分类异常冒出 `chat()`(2026-09-05 独立验证实测)
"""
def _capturing_client(self, captured, **overrides):
def handler(request):
captured.append(json.loads(request.content))
return _sse()
return _client(handler=handler, **overrides)
async def test_bare_string_tier_reaches_the_wire(self):
captured = []
source = _source(provider="zhipu", model="glm-5.3")
async with self._capturing_client(captured, sources=[source]) as client:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="max")
assert captured[0]["reasoning_effort"] == "max"
async def test_illegal_tier_is_a_value_error_listing_the_vocabulary(self):
"""非法档位是调用方编程错误: 当场 `ValueError`,不进洋葱、不成为未分类异常。"""
source = _source(provider="zhipu", model="glm-5.3")
async with _client(sources=[source]) as client:
with pytest.raises(ValueError) as exc:
await client.chat([{"role": "user", "content": "hi"}], reasoning_effort="lowest")
message = str(exc.value)
assert "chat(reasoning_effort=...)" in message
assert all(tier.value in message for tier in Effort)
class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
class TestCallerDimensions:
"""调用方自定义维度进遥测(issue #11 Task 4)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_dimensions_reach_telemetry_row(self):
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"})
row = recorder.rows[-1]
assert row["tenant_id"] == "t1"
assert json.loads(row["meta"]) == {"batch": "b-42"}
async def test_default_path_writes_sentinels(self):
"""不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG)
row = recorder.rows[-1]
assert row["tenant_id"] == "" and row["meta"] == "{}"
async def test_invalid_meta_key_rejected_before_any_telemetry(self):
"""校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
assert recorder.rows == []
async def test_non_finite_float_rejected_before_any_telemetry(self):
"""nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="nan"):
await client.chat(self._MSG, meta={"k": float("nan")})
assert recorder.rows == []
async def test_meta_does_not_enter_cache_key(self):
"""仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。
带对照组: 只断言"命中"的话,缓存 key 退化成常量(忽略一切输入)时本用例
照样绿那是恒真断言故再改一个**确实进 key** 的维度(namespace)断言
miss,证明 key 仍在区分输入,"meta 不进 key"才是被测出来的结论
"""
cache = InMemoryCache() # 两个 client 共用一份存储,否则对照组的 miss 是白来的
client = _client(cache=cache, cache_namespace="proj", cache_ttl_s=3600)
async with client:
first = await client.chat(self._MSG, meta={"batch": "b-1"})
second = await client.chat(self._MSG, meta={"batch": "b-2"})
assert first.cache_hit is False and second.cache_hit is True
other_ns = _client(cache=cache, cache_namespace="other", cache_ttl_s=3600)
async with other_ns:
assert (await other_ns.chat(self._MSG, meta={"batch": "b-1"})).cache_hit is False
class TestModelFingerprint:
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
@@ -244,6 +566,61 @@ class TestModelFingerprint:
b = build_model_fingerprint([_source(extra_body={"temperature": 1})])
assert a != b
def test_source_tier_enters_fingerprint(self):
"""源级 `reasoning_effort` 改变请求体,就必须改变缓存身份(与 issue #5 同理)。
本用例同时守着一个易漏点: 只配 `REASONING_EFFORT`既无 `extra_body` 也无
`ENABLE_THINKING` 的源,必须能进入指纹的 marks 集合否则 `_fingerprint_mark`
改了也白改,四个指纹会全部相等
"""
from polygateway.client import build_model_fingerprint
plain = build_model_fingerprint([_source()])
low = build_model_fingerprint([_source(reasoning_effort=Effort.LOW)])
max_ = build_model_fingerprint([_source(reasoning_effort=Effort.MAX)])
off = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
assert len({plain, low, max_, off}) == 4
def test_source_tier_is_distinguished_from_the_thinking_sugar(self):
"""`reasoning_effort=NONE` 与 `enable_thinking=False` 不得摘要成同一个指纹。
两者语义等价但取值不同(`"none"` vs `false`),让它们撞车会把"两种写法"
变成"一种缓存身份",日后任一侧语义微调都会静默复用另一侧的响应
"""
from polygateway.client import build_model_fingerprint
by_tier = build_model_fingerprint([_source(reasoning_effort=Effort.NONE)])
by_sugar = build_model_fingerprint([_source(enable_thinking=False)])
assert by_tier != by_sugar
def test_absent_tier_fingerprint_is_byte_identical_to_before(self):
"""不表态档位的存量源不得因本次升级平白冷启动: 字面量逐字相同。
两条: 纯净源仍是裸 model 合集;只配 extra_body 的源仍是升级前那个摘要
"""
import hashlib
import json
from polygateway.client import build_model_fingerprint
assert build_model_fingerprint([_source()]) == "qwen-max"
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
def test_declared_tier_fingerprint_is_a_golden(self):
"""配了档位那一侧的指纹字面量也要钉死: 它变了就是该源整段缓存冷启动。
存量(不表态)那侧由 `test_absent_tier_fingerprint_is_byte_identical_to_before`
守着;本条守的是"档位怎么摘要进 mark"字面量硬编码,不在测试里重算
重算等于把实现抄一遍,实现改了两边一起变,断言就白写了
"""
from polygateway.client import build_model_fingerprint
assert build_model_fingerprint([_source(reasoning_effort=Effort.LOW)]) == (
"qwen-max|76f2b3e419e1a727f7f31b6144da0b40d62a00ca93c91ce5901b4ebd48c0b8c0"
)
class TestFactories:
def test_from_env_assembles(self):
@@ -285,6 +662,84 @@ class TestFactories:
assert isinstance(client, GatewayClient)
class TestTelemetryTextCapWiring:
"""`PGW_TELEMETRY_TEXT_CAP` 必须走通全部三条 `from_settings` 装配路(issue #12)。
三条链路写的是**同一张** `llm_calls` :只接通 chat,embed OCR 的行就
永远不受 cap 约束,同表内一半受控一半不受控那正是本 issue 要消灭的状态
"""
_CAP_ENV = dict(_ENV, PGW_TELEMETRY_TEXT_CAP="8")
_OCR_CAP_ENV = dict(_OCR_ENV, PGW_TELEMETRY_TEXT_CAP="8")
def test_gateway_from_settings_wires_the_cap(self):
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
client = GatewayClient.from_settings(settings, telemetry=_MemoryRecorder())
assert client._terminal._emitter._text_cap == 8
# 对照组: 不设该键时 emitter 拿到的必须是 None,否则 8 可能是硬编码来的
unset = GatewayClient.from_settings(
GatewaySettings.from_env("LLM", env=_ENV), telemetry=_MemoryRecorder()
)
assert unset._terminal._emitter._text_cap is None
def test_embedding_from_settings_wires_the_cap(self):
from polygateway.config import EmbeddingSettings
from polygateway.embedding import EmbeddingClient
gateway = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
client = EmbeddingClient.from_settings(
EmbeddingSettings(gateway=gateway, batch_size=2), telemetry=_MemoryRecorder()
)
assert client._emitter._text_cap == 8
unset = EmbeddingClient.from_settings(
EmbeddingSettings(gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2),
telemetry=_MemoryRecorder(),
)
assert unset._emitter._text_cap is None
def test_ocr_from_settings_wires_the_cap(self):
from polygateway.config import OcrSettings
from polygateway.ocr import OcrClient
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_CAP_ENV))
client = OcrClient.from_settings(settings, telemetry=_MemoryRecorder())
assert client._emitter._text_cap == 8
no_cap = dict(self._OCR_CAP_ENV)
no_cap.pop("PGW_TELEMETRY_TEXT_CAP")
unset = OcrClient.from_settings(
OcrSettings.from_env("OCR", env=no_cap), telemetry=_MemoryRecorder()
)
assert unset._emitter._text_cap is None
async def test_capped_body_reaches_the_recorder_end_to_end(self, monkeypatch):
"""装配路通了还不够: 真跑一次 chat,落库的 messages 与 response 确已截断。
`from_settings` 自建 transport(没有 client_factory 入口),故在装配点
换掉该类以接上 MockTransport洋葱其余各层仍是 `from_settings` 装的真件
"""
recorder = _MemoryRecorder()
long_text = "甲乙丙丁戊己庚辛壬癸" # 10 字,cap=8 → 略 2 字
monkeypatch.setattr(
"polygateway.client.OpenAICompatTransport",
lambda **kwargs: OpenAICompatTransport(
client_factory=lambda source: httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: _sse(content=long_text))
)
),
)
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
async with GatewayClient.from_settings(settings, telemetry=recorder) as client:
await client.chat([{"role": "user", "content": long_text}])
row = recorder.rows[-1]
assert json.loads(row["messages"])[0]["content"] == "甲乙丙丁戊己庚辛…(略 2 字)"
assert row["response"] == "甲乙丙丁戊己庚辛…(略 2 字)"
def test_non_positive_cap_rejected_on_the_direct_construction_path(self):
"""直接构造是库承诺的另一条公共装配路;cap=0 会让每条正文只剩省略标记。"""
with pytest.raises(ValueError, match="text_cap"):
_client(telemetry=_MemoryRecorder(), text_cap=0)
class TestSharedBackend:
async def test_two_clients_share_global_concurrency_gate(self):
"""VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。"""
@@ -398,3 +853,430 @@ class TestReferenceProtocolCompat:
): ...
assert isinstance(_client(), proto)
# —— 资源所有权纪律(issue #15 D 组): 谁建的谁关,注入的一律不碰 ——
_CACHE_ENV = dict(
_ENV, PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600"
)
class _Closable:
"""记 close 次数的假组件;所有权纪律的唯一观测点。"""
def __init__(self):
self.closed = 0
async def aclose(self):
self.closed += 1
class _SyncClosable:
"""只有同步 close 的假 recorder(SQLiteRecorder 形态,收敛后的 helper 须探测到)。"""
def __init__(self):
self.closed = 0
def close(self):
self.closed += 1
class _FalsyClosable(_Closable):
"""`bool()` 为假的组件(空容器形态的后端就长这样)。
所有权判定必须写 `is None` / `is not None`,不得写 `or`(设计 §3.4,ARCH §4.5
细则 2): `or` 时注入这样一个后端会**悄悄走自建分支**,而所有权标志按
`is None` 判成 False于是既没用上注入的那个,自建的那个又没人关,正是本
issue 要修的泄漏原地复活判定与标志一漂移,两个 bug 一起回来
"""
def __bool__(self):
return False
def _parts(*names):
return {name: _Closable() for name in names}
def _falsy_parts(*names):
return {name: _FalsyClosable() for name in names}
def _patch_builders(monkeypatch, built, *, transport_path):
"""把工厂的自建点换成可计数假件;transport 无注入入口,故恒自建。"""
monkeypatch.setattr(transport_path, lambda **kwargs: built["transport"])
monkeypatch.setattr("polygateway.client._build_limiter", lambda s, src: built["limiter"])
monkeypatch.setattr("polygateway.client._build_breaker", lambda s: built["breaker"])
monkeypatch.setattr("polygateway.client._build_telemetry", lambda s: built["telemetry"])
if "cache" in built:
monkeypatch.setattr("polygateway.client._build_cache", lambda s: built["cache"])
class TestGatewayClientOwnership:
"""`__init__` 是全量注入路径,经它传入的一切都归调用方(设计 §3.4)。"""
_GATEWAY_TRANSPORT = "polygateway.client.OpenAICompatTransport"
async def test_injected_components_are_never_closed(self):
"""共享 recorder/transport 被第一个关闭的 client 弄死,正是 R5 显式共享走不通的原因。"""
injected = _parts(*("transport", "telemetry", "cache", "limiter", "breaker"))
client = _client(
transport=injected["transport"],
telemetry=injected["telemetry"],
cache=injected["cache"],
cache_namespace="proj",
cache_ttl_s=3600,
limiter=injected["limiter"],
breaker=injected["breaker"],
)
await client.aclose()
assert {name: part.closed for name, part in injected.items()} == {
"transport": 0,
"telemetry": 0,
"cache": 0,
"limiter": 0,
"breaker": 0,
}
async def test_factory_closes_every_component_it_built(self, monkeypatch):
"""泄漏钉子: 自建的 redis limiter/breaker 今天没人关,连引用都没留。"""
built = _parts("transport", "telemetry", "cache", "limiter", "breaker")
_patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT)
client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV))
await client.aclose()
assert {name: part.closed for name, part in built.items()} == {
"transport": 1,
"telemetry": 1,
"cache": 1,
"limiter": 1,
"breaker": 1,
}
async def test_factory_keeps_hands_off_injected_components(self, monkeypatch):
built = _parts("transport", "telemetry", "cache", "limiter", "breaker")
_patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT)
injected = _parts("telemetry", "cache", "limiter", "breaker")
client = GatewayClient.from_settings(
GatewaySettings.from_env("LLM", env=_CACHE_ENV),
limiter=injected["limiter"],
breaker=injected["breaker"],
cache=injected["cache"],
telemetry=injected["telemetry"],
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
assert built["transport"].closed == 1 # 工厂恒自建 transport,归 client
async def test_falsy_injected_components_are_still_injected(self, monkeypatch):
"""`is not None` 是所有权判定成立的**必要条件**,不是风格偏好(设计 §3.4)。
改回 `or` : 工厂拿自建件顶掉注入件(下游以为在共享,其实各跑各的),
且自建件的 `_owns_*` 仍是 False redis 客户端就地泄漏
"""
built = _parts("transport", "telemetry", "cache", "limiter", "breaker")
_patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT)
injected = _falsy_parts("telemetry", "cache", "limiter", "breaker")
client = GatewayClient.from_settings(
GatewaySettings.from_env("LLM", env=_CACHE_ENV),
limiter=injected["limiter"],
breaker=injected["breaker"],
cache=injected["cache"],
telemetry=injected["telemetry"],
)
assert client._limiter_backend is injected["limiter"]
assert client._breaker_backend is injected["breaker"]
assert client._cache is injected["cache"]
assert client._telemetry is injected["telemetry"]
owns = (client._owns_limiter, client._owns_breaker, client._owns_cache)
assert owns == (False, False, False) and client._owns_telemetry is False
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
# 自建件根本不该被造出来更不该被关;只有恒自建的 transport 归 client
assert [built[name].closed for name in ("telemetry", "cache", "limiter", "breaker")] == [
0,
0,
0,
0,
]
async def test_aclose_is_idempotent(self, monkeypatch):
built = _parts("transport", "telemetry", "cache", "limiter", "breaker")
_patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT)
client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV))
await client.aclose()
await client.aclose()
assert all(part.closed == 1 for part in built.values())
async def test_sync_only_recorder_is_closed(self, monkeypatch):
"""SQLiteRecorder 只有同步 `close()`;收敛成 helper 之后这条分支不得丢。"""
built = _parts("transport", "cache", "limiter", "breaker")
recorder = _SyncClosable()
built["telemetry"] = recorder
_patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT)
client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV))
await client.aclose()
assert recorder.closed == 1
def _embedding_client(**overrides):
from polygateway.embedding import EmbeddingClient
defaults = {
"scope": "embed",
"sources": [_source()],
"selector": RoundRobinSelector(),
"limiter": _Closable(),
"breaker": _Closable(),
"transport": _Closable(),
"retry": RetryPolicy(3, 2.0, 30.0),
"backpressure": BackpressurePolicy(300.0, 0.01),
"batch_size": 2,
}
defaults.update(overrides)
return EmbeddingClient(**defaults)
class TestEmbeddingClientOwnership:
"""三处必须各钉一次: 收敛成 helper 之后,有人把逻辑复制回去也得当场被发现。"""
async def test_injected_components_are_never_closed(self):
injected = _parts("transport", "telemetry", "limiter", "breaker")
client = _embedding_client(
transport=injected["transport"],
telemetry=injected["telemetry"],
limiter=injected["limiter"],
breaker=injected["breaker"],
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
async def test_factory_closes_every_component_it_built(self, monkeypatch):
from polygateway.config import EmbeddingSettings
from polygateway.embedding import EmbeddingClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.openai_compat.OpenAICompatTransport",
)
settings = EmbeddingSettings(
gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2
)
client = EmbeddingClient.from_settings(settings)
await client.aclose()
assert all(part.closed == 1 for part in built.values())
async def test_factory_keeps_hands_off_injected_components(self, monkeypatch):
from polygateway.config import EmbeddingSettings
from polygateway.embedding import EmbeddingClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.openai_compat.OpenAICompatTransport",
)
injected = _parts("telemetry", "limiter", "breaker")
settings = EmbeddingSettings(
gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2
)
client = EmbeddingClient.from_settings(
settings,
limiter=injected["limiter"],
breaker=injected["breaker"],
telemetry=injected["telemetry"],
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
assert built["transport"].closed == 1
async def test_falsy_injected_components_are_still_injected(self, monkeypatch):
"""三处工厂各写一遍 `is not None`,就是三处各有一次漂移回 `or` 的机会。"""
from polygateway.config import EmbeddingSettings
from polygateway.embedding import EmbeddingClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.openai_compat.OpenAICompatTransport",
)
injected = _falsy_parts("telemetry", "limiter", "breaker")
settings = EmbeddingSettings(
gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2
)
client = EmbeddingClient.from_settings(
settings,
limiter=injected["limiter"],
breaker=injected["breaker"],
telemetry=injected["telemetry"],
)
assert client._limiter_backend is injected["limiter"]
assert client._breaker_backend is injected["breaker"]
assert client._telemetry is injected["telemetry"]
assert (client._owns_limiter, client._owns_breaker, client._owns_telemetry) == (
False,
False,
False,
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
assert [built[name].closed for name in ("telemetry", "limiter", "breaker")] == [0, 0, 0]
def _ocr_client(**overrides):
from polygateway.ocr import OcrClient
defaults = {
"scope": "ocr",
"sources": [_source(name="m1", provider="monkey", model="monkey-ocr")],
"selector": RoundRobinSelector(),
"limiter": _Closable(),
"breaker": _Closable(),
"transport": _Closable(),
"retry": RetryPolicy(3, 2.0, 30.0),
"backpressure": BackpressurePolicy(300.0, 0.01),
}
defaults.update(overrides)
return OcrClient(**defaults)
class TestOcrClientOwnership:
async def test_injected_components_are_never_closed(self):
injected = _parts("transport", "telemetry", "limiter", "breaker")
client = _ocr_client(
transport=injected["transport"],
telemetry=injected["telemetry"],
limiter=injected["limiter"],
breaker=injected["breaker"],
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
async def test_factory_closes_every_component_it_built(self, monkeypatch):
from polygateway.config import OcrSettings
from polygateway.ocr import OcrClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport",
)
client = OcrClient.from_settings(OcrSettings.from_env("OCR", env=dict(_OCR_ENV)))
await client.aclose()
assert all(part.closed == 1 for part in built.values())
async def test_factory_keeps_hands_off_injected_components(self, monkeypatch):
from polygateway.config import OcrSettings
from polygateway.ocr import OcrClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport",
)
injected = _parts("telemetry", "limiter", "breaker")
client = OcrClient.from_settings(
OcrSettings.from_env("OCR", env=dict(_OCR_ENV)),
limiter=injected["limiter"],
breaker=injected["breaker"],
telemetry=injected["telemetry"],
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
assert built["transport"].closed == 1
async def test_falsy_injected_components_are_still_injected(self, monkeypatch):
from polygateway.config import OcrSettings
from polygateway.ocr import OcrClient
built = _parts("transport", "telemetry", "limiter", "breaker")
_patch_builders(
monkeypatch,
built,
transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport",
)
injected = _falsy_parts("telemetry", "limiter", "breaker")
client = OcrClient.from_settings(
OcrSettings.from_env("OCR", env=dict(_OCR_ENV)),
limiter=injected["limiter"],
breaker=injected["breaker"],
telemetry=injected["telemetry"],
)
assert client._limiter_backend is injected["limiter"]
assert client._breaker_backend is injected["breaker"]
assert client._telemetry is injected["telemetry"]
assert (client._owns_limiter, client._owns_breaker, client._owns_telemetry) == (
False,
False,
False,
)
await client.aclose()
assert all(part.closed == 0 for part in injected.values())
assert [built[name].closed for name in ("telemetry", "limiter", "breaker")] == [0, 0, 0]
class TestRedisCacheOwnership:
"""组件内部自建的连接归组件自己;照抄 RedisLimiter._owns_client 的正确先例。"""
async def test_injected_client_is_not_closed(self):
from polygateway.backends.redis_cache import RedisCache
client = _Closable()
await RedisCache(client).aclose()
assert client.closed == 0
async def test_self_built_client_is_closed_once(self, monkeypatch):
from types import SimpleNamespace
from polygateway.backends import redis_cache
built = _Closable()
monkeypatch.setattr(
redis_cache, "aioredis", SimpleNamespace(from_url=lambda url, **kwargs: built)
)
cache = redis_cache.RedisCache.from_url("redis://localhost:6379/0")
await cache.aclose()
await cache.aclose() # 幂等: 不重复关
assert built.closed == 1
class TestTelemetryStatusExposure:
"""降级状态的只读出口: 一处 isinstance 判定,三个 client 各钉一次(设计 §3.3)。"""
def _recorder(self, tmp_path):
from polygateway.telemetry.sqlite import SQLiteRecorder
return SQLiteRecorder(tmp_path / "telemetry.db", auto_migrate=True)
def _assert_snapshot(self, status):
from polygateway.types import TelemetryStatus
assert isinstance(status, TelemetryStatus)
assert status.degraded is False
def test_gateway_client_without_telemetry_reports_none(self):
assert _client().telemetry_status is None
def test_gateway_client_with_foreign_recorder_reports_none(self):
"""注入的第三方 recorder 不提供状态 → None,绝不得抛 AttributeError。"""
assert _client(telemetry=_Closable()).telemetry_status is None
def test_gateway_client_with_builtin_recorder_reports_snapshot(self, tmp_path):
self._assert_snapshot(_client(telemetry=self._recorder(tmp_path)).telemetry_status)
def test_embedding_client_exposes_the_same_outlet(self, tmp_path):
assert _embedding_client().telemetry_status is None
assert _embedding_client(telemetry=_Closable()).telemetry_status is None
self._assert_snapshot(
_embedding_client(telemetry=self._recorder(tmp_path)).telemetry_status
)
def test_ocr_client_exposes_the_same_outlet(self, tmp_path):
assert _ocr_client().telemetry_status is None
assert _ocr_client(telemetry=_Closable()).telemetry_status is None
self._assert_snapshot(_ocr_client(telemetry=self._recorder(tmp_path)).telemetry_status)
+321 -3
View File
@@ -8,6 +8,8 @@ from loguru import logger
from polygateway.client import GatewayClient
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.types import Effort, SourceConfig
_BASE_ENV = {
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
@@ -132,6 +134,113 @@ class TestExtraBodyParsing:
GatewaySettings.from_env("LLM", env=env)
class TestReasoningEffortParsing:
"""源级推理档位两个键的 env 解析(issue #20 Task 4)。"""
def test_effort_key_parsed(self):
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "low"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].reasoning_effort is Effort.LOW
def test_absent_keys_keep_the_source_silent(self):
"""未配置 = 不表态,与 `Effort.NONE`(要求不推理)是两回事;映射默认关闭。"""
src = GatewaySettings.from_env("LLM", env=_env()).sources[0]
assert src.reasoning_effort is None
assert src.effort_fallback == "error"
def test_invalid_effort_lists_vocabulary(self):
"""写错档位的人要的是"那该填什么",故报错必须把八档全摆出来。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": "lowest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "REASONING_EFFORT" in message
assert all(tier.value in message for tier in Effort)
def test_effort_fallback_parsed(self):
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "nearest"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.sources[0].effort_fallback == "nearest"
def test_effort_key_tolerates_case_and_whitespace(self):
"""`.env` 里的行尾空格与大写写法是常态,档位取值本身没有大小写语义。"""
env = _env(**{"LLM__QWEN__1__REASONING_EFFORT": " LOW "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].reasoning_effort is Effort.LOW
def test_effort_fallback_tolerates_case_and_whitespace(self):
"""与相邻的 REASONING_EFFORT 同口径: 同一份 .env 里两个键脾气不同即是坑。
`EFFORT_FALLBACK=Nearest` 此前会原样落到 `SourceConfig`,被值域校验拒掉
而人看着 .env 里明明写了 nearest(2026-09-05 独立验证查出)
"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": " Nearest "})
assert GatewaySettings.from_env("LLM", env=env).sources[0].effort_fallback == "nearest"
def test_invalid_effort_fallback_rejected(self):
"""`resolve_thinking` 对未知 fallback 值是 fail-closed,不会替配置兜错。"""
env = _env(**{"LLM__QWEN__1__EFFORT_FALLBACK": "closest"})
with pytest.raises(ValueError) as exc:
GatewaySettings.from_env("LLM", env=env)
message = str(exc.value)
assert "effort_fallback" in message
assert "nearest" in message and "error" in message
class TestThinkingFlagContradiction:
"""`enable_thinking` 与 `reasoning_effort` 说的是同一件事(设计 §4.2 语法糖)。
矛盾时报错而非后者赢: 两个字段表达同一件事时,矛盾是配置错误,
静默取其一等于替下游猜它想要哪个
"""
def _source(self, enable_thinking, effort):
env = _env(
**{
"LLM__QWEN__1__ENABLE_THINKING": enable_thinking,
"LLM__QWEN__1__REASONING_EFFORT": effort,
}
)
return GatewaySettings.from_env("LLM", env=env).sources[0]
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("true", "none"), ("false", "low"), ("false", "auto"), ("false", "max")],
)
def test_contradictory_thinking_flags_rejected(self, enable_thinking, effort):
with pytest.raises(ValueError) as exc:
self._source(enable_thinking, effort)
message = str(exc.value)
assert "enable_thinking" in message and "reasoning_effort" in message
@pytest.mark.parametrize(
("enable_thinking", "effort"),
[("false", "none"), ("true", "auto"), ("true", "low")],
)
def test_consistent_flags_allowed(self, enable_thinking, effort):
"""语义一致就放行: `False`+`none` 与 `True`+某个开启档都只是说了两遍。"""
src = self._source(enable_thinking, effort)
assert src.enable_thinking is (enable_thinking == "true")
assert src.reasoning_effort is Effort(effort)
def test_one_sided_declaration_never_trips_the_guard(self):
"""只配一个键是常态(存量源全是这样),不得被矛盾守卫误伤。"""
assert self._source("true", None).reasoning_effort is None
assert self._source(None, "high").enable_thinking is None
def test_contradiction_guarded_on_direct_construction(self):
"""守卫挂在构造期而非 env 解析处: 构造函数全量注入那条装配路同样过闸。"""
base = SourceConfig(
name="s1",
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk-x",
model="qwen-max",
timeout_s=60.0,
)
with pytest.raises(ValueError, match="reasoning_effort"):
dataclasses.replace(base, enable_thinking=True, reasoning_effort=Effort.NONE)
class TestResilienceKeys:
def test_flat_legacy_keys(self):
s = GatewaySettings.from_env("LLM", env=_env())
@@ -170,6 +279,18 @@ class TestResilienceKeys:
with pytest.raises(ValueError, match="probe"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))
def test_circuit_open_defaults_to_fail_fast(self):
"""issue #14: 熔断拒绝的处置策略。
缺省**不跟随** quota_full wait把最坏墙钟从毫秒抬到 stall 窗口
"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游
"""
assert GatewaySettings.from_env("LLM", env=_env()).circuit_open == "fail_fast"
waiting = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "wait"}))
assert waiting.circuit_open == "wait"
with pytest.raises(ValueError, match="CIRCUIT_OPEN"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "block"}))
def test_selector_and_quota_full(self):
# M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
s = GatewaySettings.from_env("LLM", env=_env())
@@ -331,6 +452,155 @@ class TestAssemblyGuards:
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
class TestTelemetrySchemaMode:
"""PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。
键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA
没有迁移工具没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
PG 是共享生产表,ALTER ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
而遥测是业务路径上的内联 await,故默认 manual显式设置两侧都可覆盖
"可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它
"""
def _sqlite_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="sqlite",
PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
**overrides,
)
def _pg_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
**overrides,
)
def test_unset_key_derives_auto_for_sqlite(self):
s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
assert s.telemetry_auto_migrate is True
def test_unset_key_derives_manual_for_postgres(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env())
assert s.telemetry_auto_migrate is False
def test_unset_key_derives_manual_for_none_backend(self):
"""backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
s = GatewaySettings.from_env("LLM", env=_env())
assert s.telemetry_auto_migrate is False
def test_explicit_manual_overrides_sqlite_default(self):
s = GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
)
assert s.telemetry_auto_migrate is False
def test_explicit_auto_overrides_postgres_default(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
assert s.telemetry_auto_migrate is True
def test_invalid_mode_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
)
class TestTelemetryTextCap:
"""`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。
`PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
(不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套
"""
def test_unset_key_means_no_truncation(self):
"""缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None
def test_positive_value_is_parsed_as_int(self):
s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
assert s.telemetry_text_cap == 2000
@pytest.mark.parametrize("raw", ["0", "-1"])
def test_non_positive_rejected(self, raw):
"""0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))
def test_non_integer_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
class TestTelemetryPoolKeys:
"""`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15)。
两键都带 `PG` 前缀, `PGW_TELEMETRY_PG_DSN` 一致: SQLite 侧没有池也没有
等价的写入预算旋钮,这个不对称是已知且有理由的缺省值(4 / 5.0)只写在
config 一处recorder 的两个同名参数是必填 keyword-only,不许各带一份缺省
"""
def _pg_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
**overrides,
)
def test_unset_keys_fall_back_to_the_documented_defaults(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env())
assert s.telemetry_pg_pool_max == 4
assert s.telemetry_pg_write_timeout_s == 5.0
def test_values_parsed_from_env(self):
s = GatewaySettings.from_env(
"LLM",
env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="8", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="1.5"),
)
assert s.telemetry_pg_pool_max == 8
assert s.telemetry_pg_write_timeout_s == 1.5
@pytest.mark.parametrize("raw", ["0", "-1"])
def test_non_positive_pool_max_rejected_naming_the_env_key(self, raw):
"""池上限 0 = 永远拿不到连接(遥测全灭),负数无意义。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX=raw))
@pytest.mark.parametrize("raw", ["0", "-1"])
def test_non_positive_write_timeout_rejected_naming_the_env_key(self, raw):
"""预算 0 = 每一行都当场超预算;不设预算不是这个键的写法。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"):
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=raw))
def test_non_numeric_rejected_naming_the_env_key(self):
with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"):
GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="many"))
@pytest.mark.parametrize(
("field", "value"),
[("telemetry_pg_pool_max", 0), ("telemetry_pg_write_timeout_s", 0.0)],
)
def test_direct_construction_and_replace_are_validated_too(self, field, value):
"""env 路只覆盖 from_env;直接构造与 replace 是同等官方的装配路(与 text_cap 同款)。"""
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError, match=field):
dataclasses.replace(base, **{field: value})
def test_values_reach_the_recorder(self):
"""配置到 recorder 之间不得断链——两个键唯一的作用就是抵达那里。"""
from polygateway.client import _build_telemetry
settings = GatewaySettings.from_env(
"LLM",
env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="7", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="2.5"),
)
recorder = _build_telemetry(settings)
assert recorder._pool_max == 7
assert recorder._write_timeout_s == 2.5
class TestOcrSettings:
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
@@ -476,10 +746,41 @@ class TestCrossFieldInvariants:
GatewayClient.from_settings(settings)
def test_unknown_thinking_shape_fails_at_assembly(self):
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
settings = self._thinking_sources("openai", "kimi-k3", False)
# 形态未知即报错并指路。2026-09-04 起默认表 8 段全部有形态(openai 段改发
# OpenAI 标准的 reasoning_effort),故样本改为显式注册一个未知段——守卫测的
# 是机制,不是某个段当时的配置
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
settings = self._thinking_sources("mystery", "kimi-k3", False)
with pytest.raises(ValueError, match="register_provider"):
GatewayClient.from_settings(settings)
GatewayClient.from_settings(settings, registry=register_provider(mystery))
def test_source_tier_the_model_cannot_satisfy_fails_at_assembly(self):
"""守卫必须读**源级档位**,而不只是 `enable_thinking`(设计 §4.2 三层优先级)。
变异实测: `_guard_thinking` `source_effort=source.reasoning_effort`
改成 `None`,全套单测依然全绿(2026-09-05 独立验证查出)文案里必须出现
可执行替代 `low`glm-5.3 官方关不掉推理,只报"不行"会把人推回
`extra_body` 那条绕过治理的老路(issue #20 的成因)。
"""
base = self._base()
src = dataclasses.replace(
base.sources[0], provider="zhipu", model="glm-5.3", reasoning_effort=Effort.NONE
)
with pytest.raises(ValueError) as exc:
GatewayClient.from_settings(dataclasses.replace(base, sources=(src,)))
message = str(exc.value)
assert "glm-5.3" in message
assert "low" in message
def test_openai_segment_now_assembles_with_the_standard_field(self):
# 行为变更(2026-09-04): reasoning_effort 是 OpenAI 官方字段而非厂商方言,
# 经网关的兼容端点收得下,故兜底段不再把"关闭"判为形态未知
settings = self._thinking_sources("openai", "gpt-5.5", False)
assert GatewayClient.from_settings(settings) is not None
def test_supported_combination_assembles(self):
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
@@ -507,6 +808,7 @@ class TestCrossFieldInvariants:
("telemetry_backend", "redis"),
("selector", "random"),
("quota_full", "block"),
("circuit_open", "block"),
],
)
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
@@ -555,8 +857,24 @@ class TestCrossFieldInvariants:
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
dataclasses.replace(base, telemetry_backend="postgres")
def test_none_backend_forces_auto_migrate_off(self):
"""backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。
env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
官方的装配路仍能把 True 传进来不变量归位到构造期,三条路才一致
"""
base = self._base() # telemetry_backend="none"
replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
assert replaced.telemetry_auto_migrate is False
# —— 标量域 ——
def test_non_positive_text_cap_rejected(self):
"""env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
base = self._base()
with pytest.raises(ValueError, match="telemetry_text_cap"):
dataclasses.replace(base, telemetry_text_cap=0)
def test_negative_structured_retries_rejected(self):
base = self._base()
with pytest.raises(ValueError, match="structured_max_retries"):
+39
View File
@@ -412,6 +412,45 @@ class TestEmbedTelemetry:
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
class TestEmbedCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 embed 链四层透传到每一行遥测。"""
async def test_single_batch_row_carries_dimensions(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"], tenant_id="t1", meta={"batch": "b-42"})
assert rec.rows[0]["tenant_id"] == "t1"
assert rec.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_every_batch_row_carries_the_same_dimensions(self):
"""维度属于本次 `embed()` 调用,不随批次变化。
只断言首行会漏掉"只有第一批带维度"的实现那正是逐层透传最容易漏的形态
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=1, telemetry=rec)
await client.embed(["a", "b", "c"], tenant_id="t1", meta={"batch": "b-42"})
assert len(rec.rows) == 3 # 切成三批,每批一行
assert [r["tenant_id"] for r in rec.rows] == ["t1", "t1", "t1"]
assert [r["meta"] for r in rec.rows] == ['{"batch": "b-42"}'] * 3
async def test_invalid_meta_rejected_before_any_telemetry(self):
"""校验在切批之前: 遥测层的失败都被降级成 warning,放下游等于没有校验。"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
with pytest.raises(ValueError, match="meta"):
await client.embed(["a"], meta={"Bad Key": 1})
assert rec.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"])
assert rec.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert rec.rows[0]["meta"] == "{}"
@contextlib.contextmanager
def _captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
+38 -3
View File
@@ -34,6 +34,43 @@ class TestBaseShape:
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
class TestBodyText:
"""issue #10: 非 2xx 的响应体摘要必须有承载处,否则拒绝理由事后不可查。"""
@pytest.mark.parametrize(
"cls", (PolyGatewayError, TransientError, SourceDeadError, RequestRejectedError)
)
def test_defaults_empty_and_accepts_summary(self, cls):
assert cls("boom").body_text == ""
assert cls("boom", body_text='{"error":{"code":"bad"}}').body_text == (
'{"error":{"code":"bad"}}'
)
def test_result_invalid_keeps_both_fields_apart(self):
"""`body_text`(非 2xx 的拒绝理由)与 `raw_text`(2xx 的不可解析输出)不得混用。"""
exc = ResultInvalidError("bad json", raw_text="{oops", body_text="")
assert exc.raw_text == "{oops"
assert exc.body_text == ""
@pytest.mark.parametrize(
"exc",
(
AllSourcesExhausted(scope="llm", reason="stalled", retry_after_s=1.0),
CircuitOpenError(scope="llm", retry_after_s=1.0),
GovernanceBackendError("redis down", scope="llm"),
),
)
def test_scope_level_errors_carry_no_body(self, exc):
"""scope 级失败没有单一响应体可言,空串是如实表达而非噪音。"""
assert exc.body_text == ""
def test_body_text_does_not_leak_into_str(self):
"""字段是旁路数据: 加了它不得改变任何既有异常的 str() 输出。"""
assert str(RequestRejectedError("qwen_1 请求被拒: 400", body_text="whatever")) == (
"qwen_1 请求被拒: 400"
)
class TestResultInvalid:
def test_carries_diagnosis(self):
exc = ResultInvalidError(
@@ -134,9 +171,7 @@ class TestGovernanceBackendReason:
assert "governance_backend_down" in SCOPE_REASONS
def test_gateway_unavailable_accepts_the_new_reason(self):
exc = AllSourcesExhausted(
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
)
exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0)
assert exc.reason == "governance_backend_down"
def test_retry_after_default_is_non_zero(self):
+89
View File
@@ -0,0 +1,89 @@
"""HTTP 错误响应体摘要口径(issue #10 设计 §3.2/§3.4)。
摘要是 message `body_text` 共用的**同一份串**,故它的边界行为直接决定
遥测里看到的与下游 catch 到的是否一致本组用例把规则钉成算术
"""
import httpx
import pytest
from polygateway.transports._http_errors import (
_ERROR_BODY_CAP,
_HEAD_CHARS,
_TAIL_CHARS,
compose_message,
response_body,
summarize_body,
)
# issue #10 原文给出的真实响应体(一字不改),关键在于 code 收尾
_REAL_SAMPLE = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
class TestSummarizeBody:
def test_short_body_passes_through(self):
assert summarize_body(_REAL_SAMPLE) == _REAL_SAMPLE
def test_whitespace_collapsed(self):
"""错误体常是缩进 JSON: 不折叠会把一行日志炸成多行、遥测列不可读。"""
assert (
summarize_body('{\n "error": {\n "code": "x"\n }\n}')
== '{ "error": { "code": "x" } }'
)
@pytest.mark.parametrize("raw", ("", " ", "\n\t \n"))
def test_blank_yields_empty(self, raw):
assert summarize_body(raw) == ""
def test_exactly_at_cap_is_untouched(self):
body = "x" * _ERROR_BODY_CAP
assert summarize_body(body) == body
def test_one_over_cap_is_summarized(self):
summary = summarize_body("x" * (_ERROR_BODY_CAP + 1))
assert summary != "x" * (_ERROR_BODY_CAP + 1)
assert "" in summary
def test_head_and_tail_both_survive(self):
"""头部硬切会丢掉尾部,而 JSON 错误体的 code/request_id 正在尾部。"""
body = "H" * 5000 + "T" * 5000
summary = summarize_body(body)
assert summary[:_HEAD_CHARS] == body[:_HEAD_CHARS]
assert summary[-_TAIL_CHARS:] == body[-_TAIL_CHARS:]
assert f"…(略 {10000 - _HEAD_CHARS - _TAIL_CHARS} 字)…" in summary
def test_real_sample_tail_visible_in_oversized_body(self):
"""设计 §7 用例 3c: 超长体里,追查网关方所需的 code 仍须可见。"""
summary = summarize_body("PADDING" * 1000 + _REAL_SAMPLE)
assert '"code":"invalid_parameter_error"}}' in summary
def test_idempotent(self):
"""再摘要一次不得嵌套标记,否则重复经手的串会层层套娃。"""
once = summarize_body("y" * 9999)
assert summarize_body(once) == once
class TestComposeMessage:
def test_empty_summary_leaves_message_intact(self):
assert compose_message("qwen_1 请求被拒: 400", "") == "qwen_1 请求被拒: 400"
def test_non_empty_summary_is_appended(self):
assert compose_message("qwen_1 请求被拒: 400", "{}") == "qwen_1 请求被拒: 400 | {}"
class TestResponseBody:
def test_reads_buffered_text(self):
assert response_body(httpx.Response(400, content=b'{"e":1}')) == '{"e":1}'
def test_unread_stream_degrades_to_empty(self):
"""取不到诊断信息绝不能升级为崩溃: 未读缓冲返回空串,且不触发网络读。"""
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
assert response_body(httpx.Response(400, stream=_Unread())) == ""
+44 -1
View File
@@ -19,7 +19,11 @@ from polygateway.errors import (
SourceDeadError,
TransientError,
)
from polygateway.transports.monkey_ocr import MonkeyOcrTransport, _parse_middle_json
from polygateway.transports.monkey_ocr import (
MonkeyOcrTransport,
_classify_status,
_parse_middle_json,
)
from polygateway.types import SourceConfig
@@ -306,6 +310,45 @@ class TestErrorTranslation:
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
assert ei.value.status_code == status
@pytest.mark.parametrize(
("status", "exc_type"),
[
(502, TransientError),
(429, TransientError), # 与 5xx 共用分支,但仍单列: 漏分支正是 issue #10 的成因
(401, SourceDeadError),
(404, RequestRejectedError),
],
)
async def test_body_survives_every_branch(self, status, exc_type):
"""issue #10: OCR 侧 message 原本只有 HTTP 状态码,拒绝理由同样丢失。"""
body = '{"detail":"unsupported image mode CMYK"}'
t = _transport_for(_routes(text_resp=httpx.Response(status, content=body.encode())))
with pytest.raises(exc_type) as ei:
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
assert ei.value.body_text == body
assert str(ei.value).endswith(f" | {body}")
def test_unread_body_degrades_without_changing_class(self):
"""取不到 body 时降级空串: 绝不能让 ResponseNotRead 逃出错误四分类。
直接测纯函数而非走 MockTransport真实客户端对非 stream 请求总会读完
响应,未读态只可能在将来给 OCR stream 时出现,而那正是要防的场景
"""
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
exc = httpx.HTTPStatusError(
"404",
request=httpx.Request("POST", "http://ocr.example/ocr/text"),
response=httpx.Response(404, stream=_Unread()),
)
err = _classify_status(exc, "monkey_1", "text")
assert isinstance(err, RequestRejectedError)
assert err.body_text == ""
assert str(err) == "monkey_1 OCR text HTTP 404"
async def test_connect_error_transient(self):
def handler(request):
raise httpx.ConnectError("refused", request=request)
+53
View File
@@ -480,6 +480,59 @@ class TestTelemetry:
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
class TestOcrCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 OCR 链四层透传到每一行遥测。
OCR 行与 chat 行落在同一张 `llm_calls` : 不覆盖这条链会让同一张表里
一部分行有租户归属一部分永远空白,"先启用后加列则归属无法还原"
"""
async def test_recognize_text_row_carries_dimensions(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert recorder.rows[0]["tenant_id"] == "t1"
assert recorder.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_parse_layout_row_carries_dimensions(self):
"""两个公共方法都是入口: 只测一个会漏掉另一个的透传缺口。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["layout"], telemetry=recorder)
await client.parse_layout(b"jpg", tenant_id="t2", meta={"batch": "b-43"})
assert recorder.rows[0]["tenant_id"] == "t2"
assert recorder.rows[0]["meta"] == '{"batch": "b-43"}'
async def test_failed_attempt_row_also_carries_dimensions(self):
"""失败行同样需要归属: 某租户的请求没被服务,正是审计最需要的一行。"""
recorder = _MemoryRecorder()
client, _, _ = _client(
[_src()],
[TransientError("boom", status_code=500), "text"],
telemetry=recorder,
)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert len(recorder.rows) == 2 # 失败尝试 + 成功尝试
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
with pytest.raises(ValueError, match="meta"):
await getattr(client, method)(b"jpg", meta={"Bad Key": 1})
assert recorder.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg")
assert recorder.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert recorder.rows[0]["meta"] == "{}"
class TestAssembly:
_ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
+369 -13
View File
@@ -16,12 +16,14 @@ from polygateway.errors import (
)
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
from polygateway.transports._http_errors import summarize_body
from polygateway.transports.openai_compat import (
OpenAICompatTransport,
_iter_sse_deltas,
_sse_data_payload,
)
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
def _source(**overrides):
@@ -59,20 +61,22 @@ def _sse_stream(*frames, done=True):
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
def _transport_for(handler):
def _transport_for(handler, *, registry=None):
mock = httpx.MockTransport(handler)
return OpenAICompatTransport(
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock)
client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock),
registry=registry,
)
async def _complete(transport, source, *, stream=True, overlay=None):
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None):
return await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=source,
stream=stream,
overlay=overlay or {},
call_id="cid-1",
reasoning_effort=reasoning_effort,
)
@@ -112,13 +116,14 @@ async def _recorded_cost(result, source):
source_name=source.name,
usage_source=result.usage_source,
)
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source,
call_id="cid-1",
latency_ms=1,
response=response,
error=None,
reasoning_applies=True,
)
return recorder.rows[0]["cost"]
@@ -470,6 +475,216 @@ class TestReasoningTokens:
assert result.reasoning_tokens is None
class TestThinkingObservationVerdict:
"""issue #16/#17: 两条组装路径都必须裁定"推理到底发生没发生"
流式与非流式各测一遍是刻意的只填一条路径正是本 issue 的根因形态:
库在其中一条路径上悄悄给出了不同的可观测性,下游无从分辨
"""
def _reasoning_usage(self, reasoning):
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
async def test_stream_reasoning_content_is_observed(self):
def handler(request):
return _sse_stream(
_chunk(reasoning="想一下"), _chunk(content="ok"), _chunk(usage=_USAGE)
)
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.OBSERVED
async def test_stream_without_any_signal_is_unknown(self):
"""无正文、无 details: 库不知道,就如实说不知道。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.UNKNOWN
async def test_stream_zero_reasoning_tokens_is_absent(self):
"""上游明确上报 0 才算 ABSENT——这是唯一的"确实没推理"证据。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
result = await _complete(_transport_for(handler), _source())
assert result.thinking_observation is ThinkingObservation.ABSENT
async def test_non_stream_reasoning_content_is_observed(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42", "reasoning_content": "想一下"}}],
"usage": _USAGE,
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.OBSERVED
async def test_non_stream_without_any_signal_is_unknown(self):
"""M3 非流式实测形态: 推理已计费却既不回传正文也不回传 details。"""
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.UNKNOWN
async def test_non_stream_zero_reasoning_tokens_is_absent(self):
def handler(request):
return httpx.Response(
200,
json={
"choices": [{"message": {"content": "42"}}],
"usage": self._reasoning_usage(0),
},
)
result = await _complete(_transport_for(handler), _source(), stream=False)
assert result.thinking_observation is ThinkingObservation.ABSENT
class TestThinkingReconciliation:
"""对账告警按 (source, model, direction) 节流(设计 §5)。
键的三段缺一不可,理由同源: 合并任意一段,都会让先出现的那一组把另一组
永久静音同一模型的开/关两档是两个独立的矛盾,同一模型的两个源背后是
两个独立的账号/网关
"""
def _handler(self, request):
payload = json.loads(request.content)
if payload.get("reasoning_effort") == "none":
# 关闭档却回了推理正文 → OBSERVED,与"要求关闭"矛盾
return _sse_stream(
_chunk(reasoning="偷偷想了"), _chunk(content="ok"), _chunk(usage=_USAGE)
)
# 开启档却零信号 → UNKNOWN,无法确认是否生效(M3 实测形态)
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
def _minimax(self, enable_thinking, name="mm"):
return _source(
name=name, provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
async def test_same_model_and_direction_warns_only_once(self):
transport = _transport_for(self._handler)
source = self._minimax(False)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, source)
await _complete(transport, source)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 1, f"同一 (model, direction) 应只告警一次,实得 {len(hits)}"
async def test_each_source_gets_its_own_warning(self):
"""多源多账号是本库的核心场景: 同一 model 跨 N 个源不得只喊第一个。
节流键漏掉源标识时,5 个共用同一模型的源里第一个出问题的喊完一次,其余
四个**永久静音**而每个源背后是独立的账号/网关,它们的行为互不代表
"""
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False, name="gw-a"))
await _complete(transport, self._minimax(False, name="gw-b"))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 2, f"两个源各应告警一次,实得 {len(hits)}"
async def test_the_warning_names_the_source(self):
"""拿到告警的人得知道该查哪个网关: 只报模型名定位不到源。"""
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False, name="gw-a"))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 1
assert "gw-a" in hits[0], f"告警未点名出问题的源: {hits[0]}"
async def test_switching_direction_earns_a_second_warning(self):
transport = _transport_for(self._handler)
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(False))
await _complete(transport, self._minimax(True))
await _complete(transport, self._minimax(True))
finally:
logger.remove(sink_id)
hits = [m for m in messages if "MiniMax-M3" in m]
assert len(hits) == 2, f"两个方向各应告警一次,实得 {len(hits)}"
async def test_each_tier_of_one_model_earns_its_own_warning(self):
"""同一源同一模型的两个强度档是**两个独立的矛盾**,不得共用一个节流键。
节流键沿用旧的 `enable_thinking` 三态时,两次请求的键逐字相同(都是
`None`档位根本不经过那个字段),于是 `max` 档的矛盾被 `low` 档那次
永久静音档位化后 low max 各喊一次,重复的 low 仍只喊一次
"""
transport = _transport_for(self._zero_signal)
source = _source(name="zp", provider="zhipu", model="glm-5.3")
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await _complete(transport, source, reasoning_effort=Effort.LOW)
await _complete(transport, source, reasoning_effort=Effort.LOW)
await _complete(transport, source, reasoning_effort=Effort.MAX)
finally:
logger.remove(sink_id)
hits = [m for m in messages if "glm-5.3" in m]
assert len(hits) == 2, f"low 与 max 应各告警一次,实得 {len(hits)}"
def _zero_signal(self, request):
"""零推理信号的成功响应 → UNKNOWN,与"要求开启"矛盾(M3 实测形态)。"""
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
class TestAppliedTierLeavesTheTransport:
"""本次**实际**发出去的档必须随 TransportResult 上浮(设计 §4.1 / 计划 T8-5)。
不上浮就只能由遥测自己再算一遍请求档, `nearest` 映射后两者不同压测
要按档分组的那一列会挂在一个从未真正发出过的档下,且错得看不出来
"""
def _ok(self, request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
async def test_result_carries_the_mapped_tier_not_the_requested_one(self):
"""glm-5.3 只有 low/high/max: 请求 `medium`,实际发出的是 `low`。"""
transport = _transport_for(self._ok)
source = _source(provider="zhipu", model="glm-5.3", effort_fallback="nearest")
result = await _complete(transport, source, reasoning_effort=Effort.MEDIUM)
assert result.applied_effort is Effort.LOW
async def test_result_carries_the_tier_that_was_asked_for_when_supported(self):
transport = _transport_for(self._ok)
source = _source(provider="zhipu", model="glm-5.3")
result = await _complete(transport, source, reasoning_effort=Effort.MAX)
assert result.applied_effort is Effort.MAX
async def test_no_statement_stays_none(self):
"""不表态时库既不注入也不推定模型默认档——"没看见"不许说成"发生了""""
transport = _transport_for(self._ok)
result = await _complete(transport, _source())
assert result.applied_effort is None
class TestNonStreamFastPath:
async def test_non_stream_parses_message(self):
def handler(request):
@@ -509,6 +724,10 @@ class TestRequestShaping:
@pytest.mark.parametrize(
("enable_thinking", "expected"),
# 本条断言反复过一次,记下原委以免第三次改回去:
# T2(2026-09-04)按"MiniMax 开启档本就无需参数"的**推定**把 medium 改成不注入;
# T10(2026-09-05)真实网关实测推翻该推定——M3 不发任何推理参数时 5/5 轮不推理,
# 故 medium 回归(issue #21 的权宜之计,正解是让 auto 受能力表约束)
[(True, "medium"), (False, "none")],
)
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
@@ -523,11 +742,20 @@ class TestRequestShaping:
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
)
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 # 旧形态实测被静默丢弃,不再下发
async def test_extra_body_overrides_the_profile_slot(self):
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。
固定用 **zhipu + glm-5.3 + 源级 low** 这组: 判据必须落在一个 profile
**真的写了值**的键上,两边写同一个键才谈得上谁覆盖谁不挑 minimax 是因为
它的 `on_base` 只写 `reasoning_effort` 一个键(issue #21 的权宜之计),
覆盖发生后看不见"profile 独有的那半边仍在",判据少一半
"""
seen = {}
def handler(request):
@@ -535,14 +763,17 @@ class TestRequestShaping:
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
source = _source(
name="mm",
provider="minimax",
model="MiniMax-M3",
enable_thinking=True,
name="zp",
provider="zhipu",
model="glm-5.3",
reasoning_effort="low",
extra_body={"reasoning_effort": "high"},
)
await _complete(_transport_for(handler), source)
# profile 注入的是 low,extra_body 后写故发出去的是 high;顺序一调换就变 low,
# 即下游写在 extra_body 里的覆盖被库悄悄顶掉(issue #20 的成因形态)
assert seen["reasoning_effort"] == "high"
assert seen["thinking"] == {"type": "enabled"} # profile 独有的那半边仍在
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
@@ -601,9 +832,15 @@ class TestRequestShaping:
def handler(request): # pragma: no cover - 不该走到发请求
raise AssertionError("请求不该发出")
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
# 2026-09-04 起默认表 8 段全部有形态,守卫样本改为显式注册的未知段
mystery = ProviderProfile(
name="mystery",
thinking=ThinkingWire(off=None, on_base=None, effort_key=None),
strip_think_tags=False,
)
source = _source(name="k3", provider="mystery", model="kimi-k3", enable_thinking=False)
with pytest.raises(RequestRejectedError, match="register_provider"):
await _complete(_transport_for(handler), source)
await _complete(_transport_for(handler, registry=register_provider(mystery)), source)
async def test_overlay_merged_into_payload(self):
seen = {}
@@ -691,6 +928,125 @@ class TestErrorTranslation:
await _complete(_transport_for(handler), _source())
# issue #10 原文给出的真实响应体(一字不改): 关键在于 code 收尾
_REJECT_BODY = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
class TestErrorBodyRetention:
"""issue #10: 网关说了什么必须活着离开翻译层——message 与字段各留一份。"""
@pytest.mark.parametrize(
("status", "exc"),
[
(400, RequestRejectedError),
(401, SourceDeadError),
(403, SourceDeadError),
(404, RequestRejectedError),
(500, TransientError),
(503, TransientError),
],
)
async def test_every_non_2xx_branch_keeps_the_body(self, status, exc):
def handler(request):
return httpx.Response(status, content=_REJECT_BODY.encode())
with pytest.raises(exc) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.body_text == _REJECT_BODY
# message 与字段共用同一份串: 遥测里看到的与下游 catch 到的不得打架
assert str(ei.value).endswith(f" | {_REJECT_BODY}")
assert "invalid_parameter_error" in str(ei.value)
async def test_rate_limited_429_keeps_body_and_retry_after(self):
def handler(request):
return httpx.Response(
429,
content=b'{"error":{"message":"per-minute cap 3"}}',
headers={"retry-after": "2.5"},
)
with pytest.raises(TransientError) as ei:
await _complete(_transport_for(handler), _source())
assert "per-minute cap 3" in str(ei.value)
assert ei.value.retry_after_s == 2.5 # 摘要不得干扰既有解析
async def test_insufficient_quota_429_keeps_body(self):
body = json.dumps(
{"error": {"type": "insufficient_quota", "message": "daily budget spent"}}
)
def handler(request):
return httpx.Response(429, content=body.encode())
with pytest.raises(SourceDeadError) as ei:
await _complete(_transport_for(handler), _source())
assert "daily budget spent" in str(ei.value)
async def test_oversized_insufficient_quota_still_classified_dead(self):
"""实现红线: 类型判定必须读**原文**。
摘要会破坏 JSON 结构,若改用摘要解析,超长 body 的配额耗尽将退化成普通
限速配额已耗尽的源不再 force_open,一个诊断改进就变成了治理 bug
**填充必须是多个键**,不能是单个超长字符串值: 后者的截断点落在字符串
*内部*,省略标记成了合法的字符串内容,而头尾保留又让尾部的 error 对象
幸存摘要照样解析得出 `insufficient_quota`,用例即告空转(2026-08-16
verifier 变异测试发现: 按错误写法实现,全套件 824 项依然全绿)多键
填充让截断点落在结构记号之间,摘要才真正不可解析
"""
body = json.dumps(
{**{f"k{i}": "v" * 10 for i in range(300)}, "error": {"type": "insufficient_quota"}}
)
assert len(body) > 2048
with pytest.raises(json.JSONDecodeError):
# 判别力的前提: 摘要确实不再是合法 JSON,读它必然拿不到 type
json.loads(summarize_body(body))
def handler(request):
return httpx.Response(429, content=body.encode())
with pytest.raises(SourceDeadError):
await _complete(_transport_for(handler), _source())
async def test_empty_body_leaves_no_dangling_separator(self):
def handler(request):
return httpx.Response(400, content=b"")
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source())
assert str(ei.value) == "qwen_1 请求被拒: 400"
assert ei.value.body_text == ""
@pytest.mark.parametrize("body", [b"<html>gateway down</html>", b"\xff\xfe not utf-8"])
async def test_non_json_and_non_utf8_bodies_do_not_explode(self, body):
def handler(request):
return httpx.Response(400, content=body)
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.status_code == 400 # 分类不受 body 形态影响
async def test_non_stream_path_keeps_the_body(self):
def handler(request):
return httpx.Response(400, content=_REJECT_BODY.encode())
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source(), stream=False)
assert ei.value.body_text == _REJECT_BODY
async def test_embedding_path_keeps_the_body(self):
def handler(request):
return httpx.Response(400, content=_REJECT_BODY.encode())
with pytest.raises(RequestRejectedError) as ei:
await _transport_for(handler).embed(texts=["hi"], source=_source(), call_id="cid-embed")
assert ei.value.body_text == _REJECT_BODY
class TestLifecycle:
async def test_aclose_idempotent(self):
def handler(request):
+63
View File
@@ -25,3 +25,66 @@ def test_ocr_public_surface_exported():
):
assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name
def test_telemetry_schema_sql_exported():
"""issue #13: manual 档下游需要主动索取"库要求的最小 schema"的顶层入口。
同时钉住公共面**只增这一个名字**: `missing_columns_warning` recorder 内部
共用的文案构造函数,导出它等于多一份永久承诺(库承诺公共面只增不删)
"""
assert "telemetry_schema_sql" in polygateway.__all__
assert callable(polygateway.telemetry_schema_sql)
assert "missing_columns_warning" not in polygateway.__all__
assert not hasattr(polygateway, "missing_columns_warning")
def test_telemetry_status_exported():
"""issue #15: `client.telemetry_status` 的返回类型必须能从顶层 import。
下游对账/告警要给这个快照做类型标注,若只在 `polygateway.types` ,标注就得
深入子模块,而本库的约定是顶层导出即公共 API `TelemetryStatusProvider`
****导出: 它是端口,库外无实现者,导出即多一份永久承诺
"""
assert "TelemetryStatus" in polygateway.__all__
assert polygateway.TelemetryStatus is not None
assert "TelemetryStatusProvider" not in polygateway.__all__
def test_thinking_public_surface_exported():
"""issue #16/#17: 推理决策搬进 `polygateway.thinking` 后,公共符号必须走顶层。
搬模块本身会断掉 `from polygateway.providers import ThinkingCapability` 这类
深路径 import给下游一个稳定引用点,是以后再重组不再破坏下游的前提本库
的约定是顶层导出即公共 API
`observe_thinking` / `reconcile_thinking` ****导出: 它们是 transport 内部
的裁定与对账,下游读 `LLMResponse.thinking_observation` 即可,导出即多一份
永久承诺`ThinkingResolution` ****导出它是已导出的 `resolve_thinking`
的返回类型,不导出等于下游拿得到实例却写不出类型标注
"""
for name in (
"ThinkingCapability",
"ThinkingObservation",
"ThinkingResolution",
"ThinkingUnsupportedError",
"ThinkingWire",
"get_capability",
"register_capability",
"resolve_thinking",
):
assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name
assert "observe_thinking" not in polygateway.__all__
assert "reconcile_thinking" not in polygateway.__all__
def test_every_promised_export_is_actually_importable():
"""`__all__` 里的每个名字都必须真的绑在包上。
只维护 `__all__` 而漏掉 import,`from polygateway import X` `import *`
都会当场炸,而逐个点名的用例只覆盖它当时想到的符号2026-09-04
`ThinkingWire` 正是这样漏进来的( `__all__` 里躺了一个提交却 import 不到)
"""
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
+83 -2
View File
@@ -1,24 +1,28 @@
"""ports.py 端口冻结测试(M1 设计 §4): Protocol 结构性检查 + Gate 快照校验。"""
import inspect
from typing import Any
import pytest
from polygateway.ports import (
CacheBackend,
EmbeddingTransport,
GateDecision,
GateState,
GateUpdate,
Middleware,
OcrTransport,
Permit,
ProviderGate,
RateLimiter,
SourceSelector,
StructuredOutputStrategy,
TelemetryRecorder,
TelemetryStatusProvider,
Transport,
)
from polygateway.types import LLMResponse, SourceStats
from polygateway.types import LLMResponse, SourceStats, TelemetryStatus
def _resp() -> LLMResponse:
@@ -68,7 +72,7 @@ class _DummyMw:
class _DummyTransport:
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
raise NotImplementedError
@@ -118,6 +122,8 @@ class _DummyRecorder:
model_reported,
sampling,
reasoning_tokens,
tenant_id,
meta,
) -> None: ...
@@ -139,6 +145,56 @@ def test_protocols_are_runtime_checkable(impl, protocol):
assert isinstance(impl, protocol)
class TestReasoningTierIsOnlyOnTheChatPort:
"""档位属于 chat 端口,且**只属于**它(Task 5b)。
`@runtime_checkable` 只查方法名不查签名,故协议签名本身必须被显式断言
否则实现漏改一个参数,要到运行期调用才会以 `TypeError` 现形,而那时的现场
离根因已经很远
"""
def test_chat_transport_carries_the_per_call_tier(self):
params = inspect.signature(Transport.complete).parameters
assert "reasoning_effort" in params
# 不给默认值是有意的(与 TelemetryRecorder 同一既有约定): 库外无第三方
# 实现者,写全签名成本为零,而默认值会把"漏传"变成静默的"不表态"
assert params["reasoning_effort"].default is inspect.Parameter.empty
@pytest.mark.parametrize(
("protocol", "method"),
[
(EmbeddingTransport, "embed"),
(OcrTransport, "recognize_text"),
(OcrTransport, "parse_layout"),
],
)
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
"""embedding 与 OCR 没有推理语义,给它们加档位只会静默无效(issue #4 同款决策)。"""
assert "reasoning_effort" not in inspect.signature(getattr(protocol, method)).parameters
class _DummyStatusProvider(_DummyRecorder):
@property
def telemetry_status(self) -> TelemetryStatus:
return TelemetryStatus(
degraded=False,
fatal=False,
reason=None,
degraded_for_s=None,
dropped_rows=0,
retry_after_s=None,
)
def test_status_provider_is_a_separate_optional_port():
"""状态**不得**并进 TelemetryRecorder: 那会让只实现 record_llm_call 的对象
当场不再满足 @runtime_checkable 的结构检查(设计 §3.3,Codex 审查)"""
assert isinstance(_DummyStatusProvider(), TelemetryStatusProvider)
assert isinstance(_DummyStatusProvider(), TelemetryRecorder)
assert not isinstance(_DummyRecorder(), TelemetryStatusProvider)
assert isinstance(_DummyRecorder(), TelemetryRecorder) # 这条断言是那条决策的执法点
def _decision(**overrides) -> GateDecision:
base = {
"source_name": "qwen_1",
@@ -205,6 +261,31 @@ class TestGateUpdate:
)
class TestTelemetryRecorderSignature:
"""`record_llm_call` 的冻结签名以 `inspect.signature` 实测,不凭记忆断言。
Protocol 的纪律是新增参数**不设默认值**(ports.py docstring):库外无第三方
实现者,而带默认值的参数会让 emitter 漏传时静默落默认值遥测里的租户归属
一旦静默错位,事后无从分辨是"没传"还是"就是空的"
"""
def test_caller_dimensions_are_declared(self):
import inspect
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params)
@pytest.mark.parametrize(
"name", ["tenant_id", "meta", "thinking_observation", "reasoning_effort"]
)
def test_caller_dimensions_have_no_default(self, name):
import inspect
param = inspect.signature(TelemetryRecorder.record_llm_call).parameters[name]
assert param.default is inspect.Parameter.empty
assert param.kind is inspect.Parameter.KEYWORD_ONLY
class TestOcrPorts:
"""M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。"""
+9 -5
View File
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
class TestEmitterCost:
async def test_success_row_costed(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -177,18 +177,19 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] == pytest.approx(7.2)
async def test_cache_hit_row_costs_zero(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
assert rec.rows[0]["cost"] == 0.0
async def test_failure_row_cost_none(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -196,12 +197,13 @@ class TestEmitterCost:
latency_ms=1,
response=None,
error="TransientError: boom",
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
async def test_unknown_model_none_without_blocking(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt(
request=_REQ,
source=_source(model="mystery"),
@@ -209,13 +211,14 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(model="mystery"),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
async def test_no_pricing_keeps_none(self):
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec)
emitter = TelemetryEmitter(rec, text_cap=None)
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -223,5 +226,6 @@ class TestEmitterCost:
latency_ms=1,
response=_resp(),
error=None,
reasoning_applies=True,
)
assert rec.rows[0]["cost"] is None
+95 -118
View File
@@ -1,63 +1,118 @@
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。
2026-09-04 profile 存的是 `ThinkingWire`(off / on_base / effort_key)而非两个
固定片段档位型模型(GLM-5.3kimi-k3deepseek-v4)""档需要附一个档位值,
两个固定片段表达不了
"""
import pytest
from loguru import logger
from polygateway.providers import (
DEFAULT_CAPABILITIES,
DEFAULT_PROFILES,
ProviderProfile,
ThinkingCapability,
get_capability,
ThinkingWire,
get_provider,
register_capability,
register_provider,
resolve_thinking,
)
_EXPECTED_SEGMENTS = frozenset(
{"qwen", "deepseek", "zhipu", "moonshot", "minimax", "openai", "anthropic", "google"}
)
class TestDefaultProfiles:
def test_qwen_profile(self):
p = get_provider("qwen")
assert p.thinking_on == {"enable_thinking": True}
assert p.thinking_off == {"enable_thinking": False}
assert p.strip_think_tags is True
assert p.supports_native_schema is False
def test_all_eight_profiles_registered(self):
"""issue #20: 智谱缺段,下游只能把 GLM 挂在 openai 兜底段下再手写 extra_body。"""
assert set(DEFAULT_PROFILES) == _EXPECTED_SEGMENTS
def test_deepseek_profile(self):
p = get_provider("deepseek")
assert p.thinking_on == {"thinking": {"type": "enabled"}}
assert p.thinking_off == {"thinking": {"type": "disabled"}}
assert p.strip_think_tags is False
def test_qwen_is_a_switch_with_no_tiers(self):
w = get_provider("qwen").thinking
assert w.on_base == {"enable_thinking": True}
assert w.off == {"enable_thinking": False}
assert w.effort_key is None # 百炼靠 thinking_budget 调深度,不是档位
assert get_provider("qwen").strip_think_tags is True
def test_openai_slots_are_unknown_not_empty(self):
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
def test_deepseek_carries_both_switch_and_tier(self):
w = get_provider("deepseek").thinking
assert w.on_base == {"thinking": {"type": "enabled"}}
assert w.off == {"thinking": {"type": "disabled"}}
assert w.effort_key == "reasoning_effort"
故不能下发任何厂商方言参数None = 形态未知 配了 enable_thinking 即报错,
而不是空字典那种"注入了个寂寞"的静默失效
def test_zhipu_matches_the_vendor_migration_note(self):
"""智谱官方: thinking.type=enabled + reasoning_effort 才是 GLM-5.3 的正确形态。"""
w = get_provider("zhipu").thinking
assert w.on_base == {"thinking": {"type": "enabled"}}
assert w.off == {"thinking": {"type": "disabled"}}
assert w.effort_key == "reasoning_effort"
def test_openai_family_sends_the_standard_field_only(self):
"""gpt/claude/gemini 经网关都吃 OpenAI 标准的 reasoning_effort,不下发厂商方言。
minimax 2026-09-05 起不在本组: 它的形态相同,""这一档被迫带上了一个
档位值( `test_minimax_on_tier_carries_a_tier_value`)
"""
p = get_provider("openai")
assert p.thinking_on is None and p.thinking_off is None
assert p.strip_think_tags is False
for name in ("openai", "anthropic", "google"):
w = get_provider(name).thinking
assert w.on_base == {}, name
assert w.off == {"reasoning_effort": "none"}, name
assert w.effort_key == "reasoning_effort", name
def test_minimax_profile_uses_reasoning_effort(self):
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
p = get_provider("minimax")
assert p.thinking_off == {"reasoning_effort": "none"}
assert p.thinking_on == {"reasoning_effort": "medium"}
assert p.strip_think_tags is False
def test_minimax_on_tier_carries_a_tier_value(self):
"""issue #21 的权宜之计: minimax 的""必须真写一个档位值,不能是空片段。
断言反复过一次: T2 "这些模型默认就推理"的推定把它改成 `{}`,T10 真实
网关实测推翻推定(M3 不发推理参数时 5/5 轮不推理),故逐字恢复旧版的 medium
"""
w = get_provider("minimax").thinking
assert w.on_base == {"reasoning_effort": "medium"}
assert w.off == {"reasoning_effort": "none"}
assert w.effort_key == "reasoning_effort"
def test_unknown_provider_fails_loudly(self):
"""消灭子串猜测: 未注册 provider 装配期即报错,不做模糊匹配。"""
with pytest.raises(ValueError, match="glm"):
get_provider("glm")
get_provider("glm") # 段名是 zhipu,不是 glm
with pytest.raises(ValueError):
get_provider("qwen2") # 子串相似也不放行
def test_error_lists_every_registered_segment(self):
with pytest.raises(ValueError, match="zhipu"):
get_provider("nope")
class TestWireNoneSemantics:
"""三个 `None` 语义互不重叠(issue #5 的成果,不可退回成"注入了个寂寞")。"""
def test_on_base_none_means_shape_unknown(self):
w = ThinkingWire(off=None, on_base=None, effort_key=None)
assert w.on_base is None
def test_off_none_means_no_off_shape(self):
"""有开启形态但没有关闭形态,与"整个形态未知"是两回事。"""
w = ThinkingWire(off=None, on_base={"x": 1}, effort_key=None)
assert w.on_base is not None and w.off is None
def test_effort_key_none_means_switch_only(self):
"""qwen 是这一档: 能开能关,但没有档位可谈。"""
assert get_provider("qwen").thinking.effort_key is None
def test_empty_on_base_is_not_none(self):
"""`{}` = 已知无需注入任何参数即处于该档;`None` = 不知道怎么表达。
样本 2026-09-05 minimax 换成 openai: minimax `on_base` issue #21
改回带值,不再是空片段;openai 段是现存 `{}` 语义的代表
"""
w = get_provider("openai").thinking
assert w.on_base == {} and w.on_base is not None
class TestPureFunctionRegistration:
def test_register_returns_new_mapping(self):
glm = ProviderProfile(name="glm", thinking_on={}, thinking_off={}, strip_think_tags=False)
glm = ProviderProfile(
name="glm",
thinking=ThinkingWire(off={}, on_base={}, effort_key=None),
strip_think_tags=False,
)
table = register_provider(glm)
assert get_provider("glm", registry=table) is glm
# 默认表未被污染(无可变全局状态铁律)
@@ -65,93 +120,15 @@ class TestPureFunctionRegistration:
get_provider("glm")
def test_register_on_custom_base_and_override(self):
custom_qwen = ProviderProfile(
name="qwen", thinking_on={"x": 1}, thinking_off={}, strip_think_tags=False
custom = ProviderProfile(
name="qwen",
thinking=ThinkingWire(off={}, on_base={"x": 1}, effort_key=None),
strip_think_tags=False,
)
table = register_provider(custom_qwen, base=DEFAULT_PROFILES)
assert get_provider("qwen", registry=table).thinking_on == {"x": 1}
assert get_provider("qwen").thinking_on == {"enable_thinking": True}
table = register_provider(custom, base=DEFAULT_PROFILES)
assert get_provider("qwen", registry=table).thinking.on_base == {"x": 1}
assert get_provider("qwen").thinking.on_base == {"enable_thinking": True}
def test_default_profiles_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestThinkingCapability:
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
def test_registered_models_carry_evidence(self):
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
cap = get_capability(model)
assert cap is not None and cap.evidence.strip()
def test_m3_can_disable_but_m2x_cannot(self):
assert get_capability("MiniMax-M3").can_disable is True
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
def test_unregistered_model_is_unknown(self):
assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability(True, "实测"))
assert get_capability("x-1", table=table) is not None
assert get_capability("x-1") is None # 默认表未被污染
def test_default_capabilities_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
class TestResolveThinking:
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
def test_rule1_none_injects_nothing(self):
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got == {}
@pytest.mark.parametrize("enable", [True, False])
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
with pytest.raises(ValueError, match="register_provider") as exc:
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
assert "extra_body" in str(exc.value)
def test_rule3_unregistered_model_warns_but_passes(self):
messages, sink_id = _warnings()
try:
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
finally:
logger.remove(sink_id)
assert got == {"reasoning_effort": "none"}
assert any("MiniMax-M9" in m for m in messages)
def test_rule4_cannot_disable_raises_with_the_model_name(self):
cap = get_capability("MiniMax-M2.7")
with pytest.raises(ValueError, match="MiniMax-M2.7"):
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
def test_rule4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
assert got == {"reasoning_effort": "medium"}
def test_rule5_normal_path(self):
cap = get_capability("MiniMax-M3")
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
"reasoning_effort": "none"
}
def test_unknown_shape_beats_capability_check(self):
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
cap = ThinkingCapability(can_disable=False, evidence="构造")
with pytest.raises(ValueError, match="register_provider"):
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
+433
View File
@@ -0,0 +1,433 @@
"""`tools/telemetry_retention.py` 的 SQLite 分支测试(issue #12 Task 3)。
一律经 `subprocess` 跑真实脚本 + 真实临时 SQLite 库文件: 脚本是独立运维工具
不被库 import, monkeypatch 或直接 import 私有函数测出来的"通过"与运维实际
执行的那条路径不是同一条(退出码argparse 行为stdout 全都测不到)
"""
from __future__ import annotations
import sqlite3
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from polygateway.telemetry.schema import SQLITE_DDL
_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
# 库写入 SQLite 的 created_at 是 UTC 的 'YYYY-MM-DD HH:MM:SS' 文本(schema 的
# DEFAULT (datetime('now'))),测试数据必须同款,否则字符串比较的口径就假了
_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
"VALUES (?, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, ?, ?)"
)
def _stamp(delta: timedelta) -> str:
return (datetime.now(UTC) + delta).strftime(_TIME_FORMAT)
def _make_db(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
"""按库的真实 DDL 建临时库并灌入 (call_id, tenant_id, created_at) 三元组。"""
path = tmp_path / "telemetry.db"
conn = sqlite3.connect(path)
try:
conn.executescript(SQLITE_DDL)
conn.executemany(_INSERT, rows)
conn.commit()
finally:
conn.close()
return path
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(_SCRIPT), *args],
capture_output=True,
text=True,
cwd=_ROOT,
timeout=120,
)
def _rows(path: Path) -> list[str]:
conn = sqlite3.connect(path)
try:
return [r[0] for r in conn.execute("SELECT call_id FROM llm_calls ORDER BY call_id")]
finally:
conn.close()
def _aged_db(tmp_path: Path) -> Path:
return _make_db(
tmp_path,
[
("old-1", "", _stamp(timedelta(days=-30))),
("old-2", "acme", _stamp(timedelta(days=-20))),
("old-3", "acme", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
class TestSqliteDryRun:
def test_dry_run_deletes_nothing_and_reports_counts_range_and_tenants(self, tmp_path):
"""缺省(不带 --apply)是 dry-run: 一行不删,且报出足以判断"删的是不是我想删的"的三样。"""
path = _aged_db(tmp_path)
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
assert result.returncode == 0, result.stderr
assert _rows(path) == ["fresh-1", "fresh-2", "old-1", "old-2", "old-3"]
assert "将删除行数: 3" in result.stdout
assert "created_at 范围:" in result.stdout
assert "按 tenant_id 分布" in result.stdout
# 空串是"未归属"的哨兵而非 NULL,repr 让它在输出里不被误读成缺失
assert "'acme': 2" in result.stdout
assert "'': 1" in result.stdout
assert "dry-run" in result.stdout
def test_dry_run_reports_the_actual_created_at_window(self, tmp_path):
"""时间范围报的必须是**命中行**的窗口,不是全表的。"""
path = _aged_db(tmp_path)
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
conn = sqlite3.connect(path)
try:
low, high = conn.execute(
"SELECT MIN(created_at), MAX(created_at) FROM llm_calls WHERE call_id LIKE 'old-%'"
).fetchone()
finally:
conn.close()
assert f"{low} ~ {high}" in result.stdout
class TestSqliteApply:
def test_apply_removes_only_expired_rows(self, tmp_path):
path = _aged_db(tmp_path)
result = _run(
"--backend", "sqlite", "--path", str(path), "--older-than-days", "7", "--apply"
)
assert result.returncode == 0, result.stderr
assert _rows(path) == ["fresh-1", "fresh-2"]
assert "已删除 3 行" in result.stdout
def test_older_than_days_zero_deletes_everything_before_now(self, tmp_path):
"""N=0 的边界: 截止时刻即"此刻",此刻之前的全删、之后的(未来戳)留下。"""
path = _make_db(
tmp_path,
[
("past", "", _stamp(timedelta(seconds=-5))),
("future", "", _stamp(timedelta(hours=1))),
],
)
result = _run(
"--backend", "sqlite", "--path", str(path), "--older-than-days", "0", "--apply"
)
assert result.returncode == 0, result.stderr
assert _rows(path) == ["future"]
def test_vacuum_with_apply_rewrites_the_file(self, tmp_path):
path = _aged_db(tmp_path)
result = _run(
"--backend",
"sqlite",
"--path",
str(path),
"--older-than-days",
"7",
"--apply",
"--vacuum",
)
assert result.returncode == 0, result.stderr
assert "VACUUM" in result.stdout
assert _rows(path) == ["fresh-1", "fresh-2"]
def test_deleting_from_a_db_without_the_table_is_a_backend_failure(self, tmp_path):
"""连得上但没有 llm_calls: 属"目标不可用",退出码 2 且**不**静默当成 0 行。"""
path = tmp_path / "empty.db"
sqlite3.connect(path).close()
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
assert result.returncode == 2
assert "llm_calls" in result.stderr
def test_missing_db_file_exits_two(self, tmp_path):
result = _run(
"--backend", "sqlite", "--path", str(tmp_path / "nope.db"), "--older-than-days", "7"
)
assert result.returncode == 2
assert "nope.db" in result.stderr
class TestUsageErrors:
"""参数层的一切错误都是退出码 1(argparse 默认的 2 已被本脚本改写,2 留给连接失败)。"""
def test_sqlite_with_dsn_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
)
assert result.returncode == 1
assert "--dsn" in result.stderr
def test_sqlite_without_path_exits_one(self):
result = _run("--backend", "sqlite", "--older-than-days", "7")
assert result.returncode == 1
assert "--path" in result.stderr
def test_sqlite_with_batch_size_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--batch-size",
"10",
)
assert result.returncode == 1
assert "--batch-size" in result.stderr
def test_postgres_with_vacuum_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--apply",
"--vacuum",
)
assert result.returncode == 1
assert "--vacuum" in result.stderr
def test_vacuum_without_apply_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--vacuum",
)
assert result.returncode == 1
assert "--apply" in result.stderr
def test_missing_older_than_days_exits_one(self, tmp_path):
result = _run("--backend", "sqlite", "--path", str(tmp_path / "x.db"))
assert result.returncode == 1
def test_negative_older_than_days_exits_one(self, tmp_path):
result = _run(
"--backend", "sqlite", "--path", str(tmp_path / "x.db"), "--older-than-days", "-1"
)
assert result.returncode == 1
assert "--older-than-days" in result.stderr
def test_unknown_backend_exits_one(self, tmp_path):
result = _run("--backend", "mysql", "--path", str(tmp_path / "x.db"))
assert result.returncode == 1
# --- --table 的参数分类(issue #18 设计 §4.2);真实解析行为在集成层验 ---
def test_sqlite_with_table_exits_one(self, tmp_path):
"""SQLite 库文件即目标,无 schema 概念,故 `--table` 在该分支无歧义可消。"""
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--table",
"some_schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_without_schema_qualifier_exits_one(self):
"""单段等于没声明: 目标仍由 `search_path` 决定,隐式性原样保留,故拒绝。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_empty_segment_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
".llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_quote_in_a_segment_exits_one(self):
"""含引号的复杂标识符不支持: 此时退回不给 `--table` 的路径(见 epilog)。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
'sch"ema.llm_calls',
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_naming_another_table_exits_one(self):
"""表名段锁死: 不加这条,`--table` 会把本脚本扩成"任意同形表删除工具""""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"audit.events",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
# 错误消息要当场把边界说清: 本脚本的作用域到 llm_calls 为止
assert "llm_calls" in result.stderr
class TestTableIdentifierWhitelist:
"""schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。
这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在),
**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人
"""
def test_schema_with_a_space_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"bad schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_schema_with_a_semicolon_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"a;b.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self):
"""收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://127.0.0.1:1/nope",
"--older-than-days",
"7",
"--table",
"pgw_s_a1b2c3.llm_calls",
)
assert result.returncode == 2
class TestHelp:
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
result = _run("--help")
assert result.returncode == 0
assert "维护角色" in result.stdout
assert "REVOKE" in result.stdout
assert "PARTITION" in result.stdout
def test_help_states_the_table_name_is_fixed(self):
"""`--table` 只有 schema 一段可变,这条边界必须写在运维会读到的地方。"""
result = _run("--help")
assert result.returncode == 0
assert "--table" in result.stdout
assert "只清理" in result.stdout
assert "llm_calls" in result.stdout
+94 -4
View File
@@ -24,9 +24,11 @@ from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
Effort,
GlobalLimits,
RetryPolicy,
SourceConfig,
ThinkingObservation,
TransportResult,
)
from tests.contracts.conftest import FakeClock
@@ -62,14 +64,21 @@ def _ok(content="ok"):
class FakeTransport:
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id)。"""
"""按脚本逐次返回结果或抛异常;记录每次 (source_name, call_id) 与收到的档位
`reasoning_effort` 刻意**不给默认值**, `Transport` 协议保持逐字一致:
`@runtime_checkable` 只查方法名不查签名,fake 上多一个默认值就会把"中间件漏传"
这类缺口伪装成"调用方没表态",而报错现场离根因很远
"""
def __init__(self, script):
self.script = list(script)
self.calls = []
self.efforts = []
async def complete(self, *, messages, source, stream, overlay, call_id):
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort)
action = self.script.pop(0)
if isinstance(action, Exception):
raise action
@@ -134,6 +143,37 @@ def _harness(
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
class TestRequestTierReachesTransport:
"""请求级档位必须一路穿过洋葱到达 transport(Task 5b)。
`ChatRequest` 上填了字段而中间件不搬运,"看起来配了、实际没发出去"的静默
失效正是 issue #20 里下游改用 extra_body 绕过治理的成因。
"""
async def test_request_tier_reaches_transport(self):
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.HIGH)
)
assert transport.efforts == [Effort.HIGH]
async def test_absent_tier_is_carried_as_none(self):
"""不表态也要显式传下去: 漏传与"传了 None"在协议上必须区分不开才安全。"""
mw, _, _, transport, *_ = _harness([_src("a")], [_ok()])
await mw(_REQ)
assert transport.efforts == [None]
async def test_tier_is_carried_on_every_retry_attempt(self):
"""换源重试时档位不得在第二次尝试上丢失。"""
mw, _, _, transport, *_ = _harness(
[_src("a"), _src("b")], [TransientError("boom", source_name="a"), _ok()]
)
await mw(
ChatRequest(messages=[{"role": "user", "content": "hi"}], reasoning_effort=Effort.LOW)
)
assert transport.efforts == [Effort.LOW, Effort.LOW]
class TestSuccessPath:
async def test_first_attempt_success_builds_response(self):
mw, limiter, gate, transport, sleep, _ = _harness([_src("a")], [_ok("hello")])
@@ -225,6 +265,56 @@ class TestObservabilityPassthrough:
assert resp.cached_prompt_tokens is None and resp.model_reported is None
assert resp.reasoning_tokens is None
async def test_thinking_observation_reaches_the_response(self):
"""issue #16/#17: 裁定归 transport,中间件只透传,不得在途中改判。"""
result = TransportResult(
content="ok",
thinking="想一下",
prompt_tokens=10,
completion_tokens=5,
usage_source="measured",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
thinking_observation=ThinkingObservation.OBSERVED,
)
mw, *_ = _harness([_src("a")], [result])
resp = await mw(_REQ)
assert resp.thinking_observation is ThinkingObservation.OBSERVED
async def test_unjudged_transport_result_stays_unknown(self):
"""不裁定的 transport(如 OCR)透传出来仍是 UNKNOWN,不被默认成 ABSENT。"""
mw, *_ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
async def test_applied_tier_reaches_the_response(self):
"""issue #20: 实际发出的档由 transport 裁定,本层只搬运。
搬运这一步漏掉,`LLMResponse.applied_effort` 恒为 None,而遥测正是从这个
字段取"这一行跑在哪档"整列会静默地全是 NULL
"""
result = TransportResult(
content="ok",
thinking="",
prompt_tokens=10,
completion_tokens=5,
usage_source="measured",
ttft_ms=12.0,
max_inter_token_ms=3.0,
raw={},
applied_effort=Effort.HIGH,
)
mw, *_ = _harness([_src("a")], [result])
resp = await mw(_REQ)
assert resp.applied_effort is Effort.HIGH
async def test_unstated_tier_stays_none(self):
"""不表态的调用不得被填成某个档: 那等于替调用方声称它做过一个选择。"""
mw, *_ = _harness([_src("a")], [_ok()])
resp = await mw(_REQ)
assert resp.applied_effort is None
class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self):
@@ -629,7 +719,7 @@ class TestDemotionInsertPosition:
async def test_demoted_lands_before_junk_sources(self):
# a 失败 2 次;b 可信(0.9)但会被跳过时,第三候选应是 a 而非垃圾源 c
from polygateway.middleware.retry import _demote_call_failures
from polygateway.middleware.admission import _demote_call_failures
srcs = [_src("a"), _src("b"), _src("c")]
health = {"a": 0.9, "b": 0.9, "c": 0.05}.__getitem__
@@ -637,7 +727,7 @@ class TestDemotionInsertPosition:
assert [s.name for s in out] == ["b", "a", "c"]
async def test_health_blind_demotion_still_tail(self):
from polygateway.middleware.retry import _demote_call_failures
from polygateway.middleware.admission import _demote_call_failures
srcs = [_src("a"), _src("b"), _src("c")]
out = _demote_call_failures(srcs, {"a": 2}, None)

Some files were not shown because too many files have changed in this diff Show More