32 Commits

Author SHA1 Message Date
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
45 changed files with 4227 additions and 726 deletions
+113
View File
@@ -1,5 +1,118 @@
# Changelog # Changelog
## 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) ## 1.3.0(2026-08-24)
遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。 遥测后端从此**按需占用连接、失败可自愈、降级可查询**(issue #15)。提交方在一个 `max_connections=100` 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是**人工比对**"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的。
+16 -3
View File
@@ -1,21 +1,34 @@
.PHONY: install test lint format check ci wiki wiki-check .PHONY: install test lint format check ci wiki wiki-check shared-table-gate
ENV := PolyGateway ENV := PolyGateway
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
# 留着它是因为字面量回归最常见、也最便宜拦。
shared-table-gate:
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
echo ""; \
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
exit 1; \
fi
install: install:
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]" conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
test: test:
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
lint: lint: shared-table-gate
conda run -n $(ENV) ruff check src/ tests/ --fix conda run -n $(ENV) ruff check src/ tests/ --fix
conda run -n $(ENV) lint-imports conda run -n $(ENV) lint-imports
format: format:
conda run -n $(ENV) ruff format src/ tests/ conda run -n $(ENV) ruff format src/ tests/
check: check: shared-table-gate
conda run -n $(ENV) ruff format --check src/ tests/ conda run -n $(ENV) ruff format --check src/ tests/
conda run -n $(ENV) ruff check src/ tests/ conda run -n $(ENV) ruff check src/ tests/
conda run -n $(ENV) lint-imports conda run -n $(ENV) lint-imports
+5 -2
View File
@@ -18,7 +18,8 @@
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;请求方向与实测观测矛盾时按 `(模型, 方向)` 各告警一次(能力表过期、开启未生效、注入了却观测不到);裁定结果随遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 25 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded``dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 | | 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded``dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` | | 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
@@ -351,7 +352,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` | | 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` | | 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS | | 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) | | 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--table <schema>.llm_calls` 把目标钉死**,不给则由连接的 `search_path` 推断) |
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。 **`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
@@ -361,6 +362,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。 `tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。 该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
## 错误模型(四分类) ## 错误模型(四分类)
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.0" version = "1.3.2"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
@@ -81,6 +81,7 @@ layers = [
"polygateway.config", "polygateway.config",
"polygateway.middleware", "polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured", "polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking",
"polygateway.providers : polygateway.sources", "polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming", "polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
] ]
+27 -4
View File
@@ -219,6 +219,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 消灭 `"qwen" in provider``model.split("-")[0]` 式字符串猜测。显式 provider 注册表,每个 provider 声明:thinking 参数注入方式(deepseek `{"thinking":{"type":"enabled"}}` / qwen `{"enable_thinking": True}`)、思考流字段(`reasoning_content` / `<think>` 标签剥离)、原生 schema 能力(供 D7 策略选择)、默认错误翻译细则。新 provider = 注册一个条目,不改核心类。 **决策**: 消灭 `"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 铁律) ### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。 **决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -370,7 +372,7 @@ flowchart TB
| `cache_hit` | bool | 是否缓存命中 | | `cache_hit` | bool | 是否缓存命中 |
| `call_id` | str | UUID,每次**尝试**独立 | | `call_id` | str | UUID,每次**尝试**独立 |
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens``model_reported`(2026-07-31,issue #3,见下)。 新增字段(库扩展,全部带默认值): `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,见下)
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**: **可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
@@ -381,6 +383,22 @@ flowchart TB
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。 `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**——它是结果不是请求。
**声明 × 观测对账(同批)**: `reconcile_thinking` 把请求方向(`enable_thinking`)与实测观测比对,矛盾即 warning、**不抛错**(可观测性属遥测方向,降级即 warning;且一次观测不足以否决一次成功的调用)。四种矛盾各有独立文案: 关闭请求却观测到推理(已登记 / 未登记两说,后者不得声称「能力表声称可关闭」——它根本没登记)、开启却上报未推理、开启却观测不到。`False × unknown``None × 任意` **不表态**: `unknown` 没有证伪力,拿它报警等于每次关闭调用都喊一遍,噪声即等于没有告警。节流按 per-transport-instance 的 `(source, model, direction)` 集合,与既有 `_warned_models` 同款形态但**不可复用同一个集合**(两者语义不同——一个记「未登记能力已告警过」,一个记「某源某方向的矛盾已告警过」,共用会让两种告警的生命周期纠缠;键空间本就不相交,故不是碰撞问题)。键含源名是因为多源多账号是本库的核心场景: 同一 model 跨 N 个源常态,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警定位不到该查哪个网关(源名在调用点拼进文案,不进纯判定函数的签名)。
这条对账的价值在于把「能力表过期」从**静默错觉**变成日志里的显式告警——能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),成本是一次枚举比较。但**保障只覆盖可观测路径**: M3 非流式两个信号双缺,那里的推理开关哪天失效库同样看不见,这一点不得假装有。
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。 **缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**: **`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
@@ -533,7 +551,7 @@ flowchart TB
### 7.8 遥测与成本 ### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。 **必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 **`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
@@ -541,6 +559,10 @@ flowchart TB
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。 **`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
**`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)。 (`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` 本就无目标。 **schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
@@ -622,7 +644,8 @@ src/polygateway/
├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补) ├── config.py # GatewaySettings: 多源/韧性/装配键族聚合与装配守卫(M1 增补)
├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py ├── middleware/ # retry.py / ratelimit.py / breaker.py / cache.py / telemetry.py / structured.py
├── transports/ # openai_compat.py / openai_sdk.py / monkey_ocr.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 + 选源策略 ├── sources.py # SourceConfig + 选源策略
├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现) ├── backends/ # memory/ 与 redis/(limiter、breaker、cache 状态实现)
├── telemetry/ # sqlite.py / postgres.py / pricing.py ├── telemetry/ # sqlite.py / postgres.py / pricing.py
@@ -630,7 +653,7 @@ src/polygateway/
└── streaming.py # 三层活性看门狗(纯函数) └── streaming.py # 三层活性看门狗(纯函数)
``` ```
**依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。 **依赖纪律**(import-linter 契约执法): `ports.py`/`types.py`/`errors.py` 为最内层,不 import 任何具体实现;`middleware/` 只依赖端口;`transports/``backends/``telemetry/``structured/` 只实现端口且互不依赖;`client.py` 是唯一的组装层。`thinking.py`(2026-08-25)夹在**实现层与 `providers` 之间**: 它 import `providers.py``ProviderProfile`(故在其上),被 `transports/``client.py` import(故在其下);契约里写作独立一层 `polygateway.thinking`,插在 `transports | backends | telemetry | structured``providers : sources` 中间。**枚举 `ThinkingObservation` 因此必须留在 `types.py`**——它是 `LLMResponse` 的字段类型,放进 `thinking.py` 会让最内层反向依赖决策层,契约当场判红。核心依赖仅 `httpx` + `pydantic`;`redis`/`aiosqlite`/`asyncpg`/`json_repair`/`openai` 全部 optional extras(`pip install polygateway[redis,telemetry-sqlite,...]`),import 失败时报清晰的"缺 extra"错误。
--- ---
+1 -1
View File
@@ -28,7 +28,7 @@
|---|---|---| |---|---|---|
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 | | 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 | | 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;错误翻译是中间件的语义地基 | | 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**——重试循环每次尝试都要选源,签名与行为一并钉死 | | 4a | `middleware/retry.py`(D13 自研,单层原则)+ `sources.py`(SourceConfig、round_robin/least_inflight 选源、源冷却备忘) | 依赖错误分类;先于限流接入便于独立测试。**多源完整行为(换源/冷却/多源行为测试)2026-07-20 人类拍板自 M2 提前进 M1**——重试循环每次尝试都要选源,签名与行为一并钉死 |
| 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 | | 4b | `backends/memory/`(limiter + breaker)+ 对应中间件 | 语义契约(permit/settle、状态机)在内存版上钉死,契约测试同步交付 |
@@ -0,0 +1,240 @@
---
type: design
node_id: design:2026-08-25-thinking-observability-design
title: "推理可观测性一等化(issue #16 + #17)"
date: 2026-08-25
---
# 推理可观测性一等化(issue #16 + #17
> 类型:design|日期:2026-08-25|状态:待人类确认
> 事实基础见 `findings/2026-08-25-thinking-observability-regression.md`(本文所有实测引用均出自该文)。
> 沿用 `2026-08-02-thinking-capability-design.md` 的先例:经充分实测后直接给出单一方案,不列备选;被否决的路见 §9。
## 1. 问题不是 issue 说的那个
issue #16/#17Gitea `iomgaa/PolyGateway`,原文经 `tea issues 16` / `17` 读取;本仓库 remote 非 GitHub`gh` 读不到)判定"MiniMax-M3 开启推理静默失效,模型不推理"。**实测推翻了这个诊断**:M3 的推理完全正常——流式路径下 `reasoning_content` 有 124 字符完整推理过程,`prompt_tokens` 194→216、`completion_tokens` 3→60,三个独立信号一致。
真正发生的是:**MiniMax 这一路上游不再返回 `usage.completion_tokens_details`**qwen 与 deepseek 在同一网关同一 key 上照常返回),于是 `reasoning_tokens` 恒为 NULL;而 e2e 的四条用例把 `reasoning_tokens` 当作唯一判据,于是集体判红。
**库自己握着决定性证据却没用它**`LLMResponse.thinking` 在同一次调用里是 185 字符的实打实推理正文,从未参与任何"推理是否发生"的判定。
所以这是一次**可观测性缺口**,不是功能故障。而缺口的形态——库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 `None`——正是 P5 要消灭的静默掩盖。
## 2. 根因三层
| # | 缺陷 | 只修外层会留下什么 |
|---|---|---|
| ① | `reasoning_tokens=None` 同时承载"没推理"与"没上报"两个语义,不可区分。`types.py` 的 docstring **已经写明这个歧义,但只是描述它,没有解决它** | 换个供应商停报 ctd,同样的红再来一次 |
| ② | 解析出的 `thinking` 文本从未接入任何判定:e2e、遥测、下游看的都只有 `reasoning_tokens` | 库继续把手里的硬证据丢在地上 |
| ③ | 能力表是**静态单向**声明(只有 `can_disable`),且没有任何机制把声明与运行时观测对账 | **下一个同构故障已在等着** |
第 ③ 层最要紧。设想某天 M3 变成不能关推理:库照常注入 `reasoning_effort=none`,模型照常推理,下游拿到推理内容却以为关了,而库全程不吭声——与本次同构,且更隐蔽(本次至少有测试变红,那次连测试都是绿的,因为 L1 的判据同样只看 `reasoning_tokens`)。能力表过期是**必然事件**(M3 的 evidence 停在 8-02 整整 23 天),设计必须把它当常态处理,而不是靠人记得去复测。
## 3. 设计主张
一句话:**把"这次推理到底发生没发生"从下游的猜测变成库的一等返回值,由多信号裁定;单次响应判不出来时如实说"未知",绝不伪装成"没有";并用它与能力表持续对账,让声明过期成为可报警事件。**
三条纪律贯穿全文:
- **能从数据可靠推断的,绝不进静态表。** 静态表必然过期,这次就是。
- **判不出来就叫"未知",不许折叠进"没有"。** 折叠是 ① 的病根。
- **最硬的证据优先。** 推理正文是事实本身,token 计数是对事实的转述;转述缺失时事实仍然作数。
## 4. 数据模型
### 4.1 `ThinkingObservation` 三态(新增,响应侧)
```python
class ThinkingObservation(StrEnum):
OBSERVED = "observed" # 确证推理发生
ABSENT = "absent" # 确证未推理(正面证据)
UNKNOWN = "unknown" # 无任何信号,判不出来
```
**枚举定义在 `types.py`,裁定逻辑在 `thinking.py`——两者必须分开。** 它是 `LLMResponse`/`TransportResult` 的字段类型,而 `types.py` 是最内层、不得 import 任何具体实现(P7import-linter 契约执法)。把枚举放进 `thinking.py` 会让最内层反向依赖决策模块,契约当场判红。纯值类型归最内层、决策逻辑归上层,是本设计的分层落法。
`StrEnum` 而非裸 `str` 常量:取值域显式、可类型检查,且它是 `str` 子类,`dataclasses.asdict` + `json.dumps` 天然可序列化(缓存回放路径见 §6)。
裁定纯函数 `observe_thinking(*, thinking: str, reasoning_tokens: int | None) -> ThinkingObservation`,四条分支按顺序:
| 条件 | 结果 | 理由 |
|---|---|---|
| `thinking.strip()` 非空 | OBSERVED | 推理正文是事实本身,压倒一切 |
| `reasoning_tokens > 0` | OBSERVED | 上游明确上报了推理用量 |
| `reasoning_tokens == 0` | ABSENT | 上报了且为零 = "未推理"的正面证据 |
| 其余(`None` | UNKNOWN | 无信号,不猜 |
**判据取 `bool(thinking.strip())` 而非 `bool(thinking)`**transport 收集 `reasoning_content` 时只判 truthy`openai_compat.py`),上游返回纯空白串就会被计成"观测到推理"。网关响应是外部输入,校验后使用(P5)。
映射到实测:
| 场景 | observation | 是否诚实 |
|---|---|---|
| M3 开启,流式 | OBSERVED | ✅ 有 185 字符正文 |
| M3 开启,非流式 | UNKNOWN | ✅ 确实观测不到(正文与 ctd 双缺) |
| M3 关闭 | UNKNOWN | ✅ 判不出——**且必须承认判不出**,见下 |
| qwen 开启 | OBSERVED | ✅ 两个信号都在 |
**`UNKNOWN` 不具证伪力,不得声称它能保障关闭方向。** M3 关闭档落在 `UNKNOWN`,这意味着库无法证明推理真的关掉了。对账(§5)能提供的保障只有一个方向:**若模型真的推理了,可观测路径会把结果翻成 `OBSERVED`,告警随之触发**——M3 流式正属此列(关闭档若失效,正文会冒出来)。而不可观测路径(M3 非流式)没有任何保障,这一点必须写在文档里而不是假装有。**告警覆盖的是可观测路径,不是全部路径**。
`ABSENT` 这一支在当前三家供应商上**实测永不触发**(未推理时都是整个容器缺失,无人报 `0`)。仍然保留:协议允许上报 `0`,而一旦有供应商这么做,它就是唯一能把"没推理"与"没上报"分开的信号——为一个已知会出现的未来留一个空槽,不是 YAGNI 违例。
### 4.2 明确不做:不把"可观测性"写进能力表
诱惑很大:给 `ThinkingCapability` 加一个 `reports_reasoning_usage: bool``observable_in_non_stream: bool`。**否决**。理由是本次故障的教训本身——静态声明会过期,而过期表现为静默错觉。可观测性每次响应都能直接看出来,把它冻进静态表等于再造一个 8-02 版本的定时炸弹。
同理否决"看 `completion_tokens_details` 容器在不在"这一判据:实测三家在未推理时都是容器整体缺失,该信号与真实信号高度混淆,用它裁定等于把噪声当信号。
## 5. 对账:声明 × 观测
在 transport 拿到结果处做一次比较,矛盾即 warning:
| 请求方向 | 观测 | 能力表 | 处置 |
|---|---|---|---|
| `enable_thinking=False` | OBSERVED | 已登记 `can_disable=True` | **warning**:能力表漂移——声明说可关闭,实测推理了。附 model 与 `evidence` 日期,指路 `register_capability` |
| `enable_thinking=False` | OBSERVED | 未登记 | **warning**:关闭请求未被满足,且该模型能力未登记。指路实测后 `register_capability` |
| `enable_thinking=True` | ABSENT | 任意 | **warning**:注入了开启参数,上游明确上报未推理 |
| `enable_thinking=True` | UNKNOWN | 任意 | **warning 一次**:推理参数已注入但本路径观测不到,无法确认是否生效;**若为非流式路径,推理内容可能已计费却不回传**(M3 实测 completion 53 vs 关闭档 3 |
| `False` | UNKNOWN | 任意 | 不表态——不能证伪(§4.1) |
| `None`(不干预) | 任意 | 任意 | 不表态——调用方没提要求,无从谈"违背" |
前两行必须分开:`resolve_thinking` 的 Phase 3 允许未登记模型按 provider 形态尽力注入并预先 warning,那是**事前猜测**;这里的对账是**事后实证**,两者文案不能混。对未登记模型说"能力表声称可关闭"是错的——它根本没登记。
第四行是 issue #17 关切的"静默失效"的诚实版本:库不再默不作声,而是明说"我注入了,但我看不见结果"。M3 非流式每次都落这一档,故节流不可少。
**不抛错**,三条理由:一次观测不足以否决一次成功的调用;P5 的降级方向铁律只对限流/熔断要求"报错而非放行",可观测性属遥测方向,降级即 warning;矛盾结果已随 `LLMResponse` 与遥测落地,处置权归下游。
**节流**per transport 实例的 `set[(source, model, direction)]`,同一组合只喊一次,与既有 `_warned_models` 同款形态与同款理由(逐次调用刷屏会把告警变成噪声,噪声等于没有告警)。键含**源名**是因为多源多账号是本库的核心场景:同一 model 跨 N 个源是常态,而每个源背后是独立的账号/网关,漏掉源名会让第一个出问题的源喊完之后其余源永久静音,且告警文案定位不到该查哪个网关(源名在调用点拼进文案,不进 `reconcile_thinking` 的签名——那是纯判定函数,源名是定位信息而非判据)。两个 set 分开维护的理由是**语义不同**(一个记"未登记能力已告警过",一个记"某源某方向的矛盾已告警过"),共用会让两种告警的生命周期纠缠在一起;不是键会碰撞——两者键空间本就不相交。
这一条是本设计的灵魂:它把"能力表过期"从**静默错觉**变成**日志里的显式告警**,成本是一次枚举比较。
## 6. 落点清单
**源码**
| 文件 | 变更 |
|---|---|
| `types.py` | 新增 `ThinkingObservation`(枚举归最内层,§4.1);`LLMResponse``thinking_observation: ThinkingObservation = UNKNOWN`(只增不删,迁移兼容);`TransportResult` 同增 |
| `thinking.py`(**新建**) | 推理这件事的全部**决策**,见 §7 |
| `providers.py` | 收缩为纯注册表:`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider` |
| **`ports.py`** | `TelemetryRecorder.record_llm_call` 24 参 → 25 参。该 docstring 明定"新增参数不设默认值"(库外无第三方实现者),故两个 recorder 与全部测试替身必须同步。**这是端口 Protocol 签名变更**,属 CLAUDE.md 强制人类确认档 |
| `transports/openai_compat.py` | 组装 `TransportResult` 时调 `observe_thinking`;对账告警落此处(唯一同时握有请求方向与响应结果的地方) |
| `middleware/retry.py` | 透传新字段 |
| `middleware/telemetry.py` | `_AttemptUsage` 增一字段;三个 `emit_*` 各传一行;`_record` 签名增一参——**全部经既有单一出口 `_record` 抵达 recorder**,不新开调用点(§12 |
| **`middleware/cache.py`** | `_rehydrate``LLMResponse(**fields)`,JSON 复活的是**裸字符串**而非枚举实例:须显式转 `ThinkingObservation(...)`。域外取值(多版本共用同一 Redis 时,更新版本写入的新态)降级为 `UNKNOWN` 并单独告警,内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存响应;"整条作废"只留给真正破坏内容完整性的失败(JSON 坏了、结构化重建不过) |
| `telemetry/schema.py` | 新列 `thinking_observation TEXT`,两端 DDL + 两份 backfill + `COLUMNS`INSERT 字段 24→25,物理列 25→26 |
| `telemetry/sqlite.py``telemetry/postgres.py` | 实现新参 |
| `client.py` | import 路径改指 `thinking.py` |
| `__init__.py` | 新增包根导出,见 §7 |
**测试**
`tests/unit/``test_types.py`(默认值为 UNKNOWN、位置构造兼容、枚举归属模块)、`test_ports.py`(端口签名冻结测试与 recorder 替身)、`test_openai_compat.py`(裁定四分支、优先级、对账三类告警、节流只喊一次)、`test_retry.py`(透传)、`test_telemetry.py`(列数/列序/组装)、`test_cache.py`(回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中、内容坏了才回源)、`test_package.py`(包根导出面,比照 `TelemetryStatus` 先例)、`test_providers.py`(拆分后的注册表);`tests/integration/test_postgres_telemetry.py`(新列 backfill 与 round-trip);`tests/e2e/test_thinking_live.py`(判据重建,§8)。
**文档**(发布清单第 1 步要求构建前改完)
`README.md` 的"必录 24 字段"→ 25**须用 `inspect.signature` 实测而非凭记忆**`research-wiki/ARCHITECTURE.md` 的 D11、§5.1 响应字段、§7.8 遥测字段、§8 模块结构(补 `thinking.py`);`research-wiki/schemas/llm-calls.md`(标题仍写"22 字段",已过期两轮,本次一并订正为 25);`research-wiki/index.md`(登记本 design 与 finding;`CHANGELOG.md`(断裂项置顶,§13)。
`thinking_observation` **不进缓存 key**:它是结果不是请求。缓存回放的历史响应带回历史 observation,与 `reasoning_tokens`/`cached_prompt_tokens` 的既有回放口径一致。
默认值取 `UNKNOWN` 使得任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
## 7. 模块边界:为什么新建 `thinking.py`
现状 `providers.py` 装着两件事:provider 注册表(形态)与推理决策(`resolve_thinking` + 能力表)。加入响应侧裁定与对账后它会变成"推理这件事的一切",一句话说不清职责(P3)。
| 模块 | 职责 | 内容 |
|---|---|---|
| `providers.py` | **provider 是什么** | `ProviderProfile``DEFAULT_PROFILES``get_provider``register_provider` |
| `thinking.py` | **推理这件事的全部决策** | `ThinkingCapability``DEFAULT_CAPABILITIES``get_capability``register_capability``resolve_thinking`(请求侧注入)、`ThinkingUnsupportedError``observe_thinking`(响应侧裁定)、对账告警。**不含 `ThinkingObservation` 定义**——纯值类型归 `types.py`(§4.1 |
符合 P7"决策逻辑与状态存储分离":注册表存声明,`thinking.py` 做决策。未来任何推理相关能力都有唯一归属,不必再挑"放哪个文件"。
**同时把公共符号提升到包根导出**`ThinkingCapability``ThinkingObservation``register_capability``get_capability``resolve_thinking``ThinkingUnsupportedError``__init__.py` 的 docstring 早已写明"顶层导出即公共 API 面",而这些符号此前只能深路径 import——**给下游一个稳定引用点,才是模块重组不再破坏下游的前提**。这是本次一并消除的第四项债务。
破坏面:`from polygateway.providers import ThinkingCapability / resolve_thinking / get_capability / DEFAULT_CAPABILITIES` 会断。这些符号不在包根 `__all__` 内,且三个参考项目尚未迁移接入(M4 未完成),实际下游为零。CHANGELOG 显式列出并给出改法。
## 8. e2e 判据重建
四条红用例的病根是判据盲区,不是被测行为。逐条重建:
| 用例 | 旧判据 | 新判据 |
|---|---|---|
| L1 关闭 | 每轮 `reasoning_tokens in (None,0)` | 每轮**不是 OBSERVED**。证伪力不减反增:模型若偷偷推理,流式必带出正文 → OBSERVED → 红 |
| L2 开启 | 多数轮 `reasoning_tokens>0`,退路 `completion>100` | 多数轮 **OBSERVED****删除 `_ON_MIN_COMPLETION` 魔数退路** |
| L2b 锚点 | `prompt_tokens` 两档分开 | 不变——它一直是对的,也是本次开启方向唯一没红的证据 |
| L3b 非法值反证 | 非法值多数轮推理 | 同 L2 判据;补注 provider 不可移植性(minimax 返 200 照常推理,qwen 返 400 |
| L4 extra_body 覆盖 | 多数轮推理 | 同 L2 判据 |
| L5 非流式 | 非流式重跑 L1/L2,要求开启档观测到推理 | **重新定义**,见下 |
删掉 `_ON_MIN_COMPLETION` 是有意的。它是"`reasoning_tokens` 被中转吃掉时的退路",而实测两档的 completion 分布重叠(关闭档最高 46、开启档最低 13),这个退路从一开始就不成立——它让判据看起来有兜底,实则在噪声里画了条线。有了 `thinking` 正文这个真信号,魔数退路失去存在理由。
**L5 是本次改动里最重要的一条。** M3 非流式下推理正文与 ctd 双双缺失(实测),旧断言"非流式开启档应观测到推理"**永远不可能成立**——它断言的是一件事实上不发生的事。新断言改为两条:其一 `prompt_tokens` 锚点在非流式下仍然分开(证明参数确实到达了模型),其二 observation 为 `UNKNOWN` 而非 `ABSENT`(证明库如实标记"观测不到"而没有伪装成"没推理")。
**从"断言一件不成立的事"变成"断言库对这件事的诚实"**——这正是本设计要立的规矩。
同时在 e2e 报告与 `DEFAULT_CAPABILITIES` 的 evidence 里登记:M3 非流式路径推理不可观测,下游用非流式开推理会**付费买看不见的推理**(completion 53 vs 关闭档 3)。库修不了上游,但必须让它可见。
## 9. 被否决的路
| 备选 | 否决原因 |
|---|---|
| 只把 e2e 判据从 `reasoning_tokens` 改成"看 `thinking` 非空" | 能让四条转绿,但 ① ③ 两层一个不动:下游拿到的仍是歧义的 `None`,能力表过期仍然静默。修的是测试不是库 |
| 给 `ThinkingCapability` 加可观测性字段 | 静态声明必然过期,等于再造一个 8-02 版定时炸弹(§4.2) |
| 用"`completion_tokens_details` 容器在不在"区分 ABSENT/UNKNOWN | 实测三家未推理时都是容器整体缺失,该信号与真实信号混淆(§4.2) |
| transport 内维护"该源历史上是否上报过推理信号"的学习态 | 行为依赖历史 → 不可复现、难测试;与"纯 asyncio 中立、无隐式状态"相抵 |
| 观测与声明矛盾时抛错 | 一次观测不足以否决一次成功调用;且与降级方向铁律的分工不符(§5) |
| 顺手把遥测四处复制的参数列表收敛为单一 helper | 见 §12 |
## 10. 非功能维度
**并发与取消**:裁定是纯函数,无 I/O、无状态;对账节流集合是 per-transport-instance 的 set,无跨实例共享、无模块级单例。`CancelledError` 路径完全不变(新增代码不在任何 await 之间持有资源)。
**降级方向**:可观测性属遥测方向 → 静默降级(warning),不报错、不阻断调用。遥测新列走既有 backfill;旧表缺列时既有的"缺列告警 + 降级写入"逻辑原样覆盖。
**幂等与重复**:纯函数,重复调用同结果。遥测 INSERT 仍走 `ON CONFLICT DO NOTHING` / `INSERT OR IGNORE`
**持久化与原子性**:仅增一列,无写入路径变化。新列排在 `created_at` 之后(旧表只能 ALTER 追加到末尾,新建库若插在前面则两条路径的物理列序分叉——既有列序纪律,不可违)。PG 侧 `TEXT` 可空、无默认值,补列只改 catalog 不重写全表。
**零业务假设**:新增词汇全部是模型调用领域术语(thinking/reasoning/observation),无业务领域词。
## 11. 错误处理与测试策略
新增裁定不产生新的失败模式,**不进四分类**。`ThinkingUnsupportedError`(装配期配置错误,`ValueError` 子类)的语义与抛出位置不变,只换模块归属。
| 层 | 覆盖 |
|---|---|
| 单元 | `observe_thinking` 四条分支 + 空白串不算 OBSERVED;对账四类告警(False×OBSERVED 已登记 / False×OBSERVED 未登记 / True×ABSENT / True×UNKNOWN)与两类不表态;节流只喊一次;`LLMResponse`/`TransportResult` 默认值为 UNKNOWN 且位置构造不破;端口签名冻结(25 参);缓存回放后仍是枚举实例、域外取值降级为 UNKNOWN 且仍命中;遥测归一化对裸 str 与域外值都不丢整行;schema 列数与列序断言(既有测试自动抓);包根导出面 |
| 集成 | SQLite/PG 新列 backfill 与 round-trip(既有测试模式) |
| e2e | §8 判据重建,合并前 `pytest -m slow` 真跑并存档报告 |
**先失败后通过的证据**`observe_thinking` 与对账的单测在字段落地前必然红;e2e 的 L2/L4 在判据改完、字段落地后应从当前 main 的 FAIL 转绿(库本来就拿到了 `thinking`,只是没人看)。L5 的新断言在旧代码上无法表达(`thinking_observation` 不存在),是纯新增覆盖。
## 12. 明确不做
**不重构遥测组装路径。** 铁律"遥测调用点收敛为单一 helper"**当前已经满足**`TelemetryEmitter._record` 是全库唯一调用 `record_llm_call` 的地方(`middleware/telemetry.py` 文件头即如此声明)。三个 `emit_*` 是三个语义不同的入口(逐次尝试 / 缓存命中 / 终态失败),各自组装参数是职责所在,不是复制粘贴债务——本次新增字段照样只经 `_record` 一个出口下沉。
**不改 M3 的 `can_disable`**2026-08-25 复测 `reasoning_effort=none` → prompt 194= 基线)、completion 3、无正文,声明依然成立。只刷新 evidence 日期并补记两条新限制(非流式不可观测、仅 `reasoning_effort` 有效)。
**不追 MiniMax 为何停报 ctd**:那是上游的事,库无从干预,也不该把自己的正确性押在它身上——本设计的全部要点正是让库在它停报时依然说得清话。
## 13. 版本号
本次含:`LLMResponse` 新增公共字段、新增模块 `thinking.py`、新增包根导出、遥测新增一列、`providers.py` 深路径 import 断裂。按语义化版本这是 **minor**。1.3.0 仅新增一个 `TelemetryStatus` 导出即定为 minor,本次变更面更大。
曾建议 1.4.0,理由是把"深路径 import 断裂"藏在 patch 版号里等于留债——下游看 1.3.0→1.3.1 不会去读 CHANGELOG。
**人类 2026-08-25 决定:发 1.3.1。** 决定已记录,实施按此执行。既然版号不再承担预警职责,预警必须由 CHANGELOG 独立扛起:断裂项与改法置于本版条目**最前**,沿用 1.3.0"请先读这一条"的体例,不得只在中段一笔带过。
## 14. 验收标准
- `observe_thinking` 四条分支与对账三种组合有单测,节流经测试确认只喊一次
- `LLMResponse.thinking_observation` 在 M3 开启流式档实测为 `OBSERVED`、非流式档为 `UNKNOWN`、qwen 开启档为 `OBSERVED`
- 遥测 SQLite/PG 两端新列均可写可读,旧表 backfill 通过,列序断言绿
- `tests/e2e/test_thinking_live.py` 全类绿(`pytest -m slow` 真跑,报告存档 `tests/outputs/e2e/`
- 端口 `record_llm_call` 25 参,两个 recorder 与全部测试替身同步,签名冻结测试绿
- 缓存回放的 `thinking_observation``ThinkingObservation` 实例而非裸字符串
- `make lint`(含 import-linter 契约,须确认 `types.py` 未 import `thinking.py`)与全套件绿
- README 的遥测字段数经 `inspect.signature` 实测更新为 25ARCHITECTURE §8 模块结构含 `thinking.py``schemas/llm-calls.md` 由过期的"22 字段"订正为 25;本 design 与 finding 进 `research-wiki/index.md`
- CHANGELOG 本版条目**最前**列出深路径 import 断裂与改法、端口签名变更、M3 非流式付费不可见推理这一事实(§13)
@@ -0,0 +1,234 @@
---
type: design
node_id: design:2026-08-26-issue18-pg-test-isolation
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
date: 2026-08-26
---
# issue #18:隔离靠**权限强制**,目标靠**显式声明**
> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**7 条写真表的用例**全迁**`public.llm_calls` 里那 11 行历史孤儿行**不清理**。
## 1. issue #18 的诊断只对了一半
issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效:
| 失效方向 | 表现 | 标 `slow` 之后 |
|---|---|---|
| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 |
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |
这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。
## 2. 根因三层
| 层 | 事实 | 后果 |
|---|---|---|
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 |
| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 |
| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 |
L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
## 3. 设计主张
1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。
## 4. 变更 A`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
### 4.1 语义:声明即目标,不是"声明后比对"
两种可能的实现要先分清:
| | 做法 | 结果 |
|---|---|---|
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
选定做法的实现落点只有一处——`_purge_postgres``to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。
三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。
### 4.2 参数与校验
| 规则 | 行为 | 理由 |
|---|---|---|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.``\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 |
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
| 无权限 | 后续 `COUNT``PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
### 4.3 目标白名单:为什么表名段不可变
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at``tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。
`--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。
### 4.4 未声明时的提示
`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行:
```
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
```
只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。
## 5. 变更 B:测试角色化——把安全网换成权限边界
### 5.1 模型
**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path``--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。
每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是:
- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
- `tmp``public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权
**必须换角色的原因**`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。
防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT``DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`
**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。
### 5.2 最坏情况从"事后观测"变成"确定性红灯"
| 情形 | 旧 | 新 |
|---|---|---|
| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** |
| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public` |
`_public_count` / `before_public` / 那条 `assert` 整体删除。
### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网
用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`
- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门)
- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆
这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。
## 6. 变更 C7 条用例迁出 `public`
| 用例 | 迁移后验的东西 |
|---|---|
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) |
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) |
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 |
### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个
| 职责 | 落点 | 处置 |
|---|---|---|
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 |
删除行隔离用途时调用点做**机械替换**(`_cid("c1")``"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。
### 6.2 顺带封掉一个仓库自己已记载的隐患
`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template``except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
## 7. 变更 D`conftest.py` 收敛 + lint 门
### 7.1 一个沙箱工厂取代七处样板
`tests/integration/conftest.py` 新增:
| fixture | 职责 |
|---|---|
| `pg_admin_dsn`session | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
| `pg_sandbox`function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |
三条硬约束(缺一条工厂就会自己变成污染源):
1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。
2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。
今天这套样板在两个文件里重复**七处**(`legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema``least_privilege_dsn``least_privilege_pre_tenant_dsn``production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。
### 7.2 机械化执法
`make lint` / `make check` 各加一步:
```
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
```
§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。
**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。
## 8. 明确不做
| 不做 | 理由 |
|---|---|
| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 |
| 建临时数据库(而非 schema | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 |
| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) |
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 |
| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 |
## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决)
| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|---|---|---|
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 |
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 |
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
## 10. 版本号与发布
**1.3.2**patch)。需在 CHANGELOG 里如实写明:`tools/``tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。
发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`
## 11. 验收标准
| # | 判据 | 验证方式 |
|---|---|---|
| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG |
| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 |
| 2 | 未给 `--table``--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 stdout",计划阶段核出该判据不可执行,就地更正) |
| 3 | 最坏情况(search_path 落到 public**删不掉任何行**且退出 2 | §5.3 新用例 |
| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
| 5 | `tests/``public.llm_calls` 零命中 | `make lint` |
| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 |
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
| 6c | `test_schema_has_frozen_columns_in_order``table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles``pgw_%` 残留 |
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
## 12. 审查留痕(Codex2026-08-26
报 6 项实质问题,**全部采纳**,其中两项为阻断级:
| # | 意见 | 处置 |
|---|---|---|
| 1 | **阻断**`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 |
| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" |
| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 |
| 4 | `test_schema_has_frozen_columns_in_order``information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 |
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 |
| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b |
**一处处置与建议不同**Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
@@ -0,0 +1,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 上,测出来的是个假现场。
+29 -1
View File
@@ -8,7 +8,7 @@
}, },
{ {
"id": "schema:llm-calls", "id": "schema:llm-calls",
"label": "表结构: llm_calls(遥测 18 字段)", "label": "表结构: llm_calls(遥测 25 字段)",
"type": "schema" "type": "schema"
}, },
{ {
@@ -384,6 +384,34 @@
"relation": "implements", "relation": "implements",
"evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)", "evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)",
"added": "2026-08-24T12:05:46.300738+00:00" "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"
} }
] ]
} }
+11 -5
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-08-24 15:51 UTC > 自动生成,更新时间:2026-08-26 11:28 UTC
## design (37) ## design (39)
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
@@ -28,6 +28,7 @@
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention` - [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode` - [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle` - [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
@@ -36,17 +37,20 @@
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget` - [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe` - [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
- [推理可观测性一等化(issue #16 + #17)](designs/2026-08-25-thinking-observability-design.md) `design:2026-08-25-thinking-observability-design`
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design` - [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error` - [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (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-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance` - [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak` - [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes` - [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload` - [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance` - [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
@@ -55,7 +59,7 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (32) ## plan (34)
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -72,6 +76,7 @@
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode` - [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle` - [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
@@ -85,6 +90,7 @@
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode` - [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle` - [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [推理开关能力建模与 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 #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
@@ -93,7 +99,7 @@
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 25 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
+6
View File
@@ -140,3 +140,9 @@
- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正 - [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:49 UTC] 重建索引: 85 篇页面
- [2026-08-24 15:51 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 篇页面
@@ -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 47 条用例迁出真表,`_RUN_PREFIX` 拆职责
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. 七条用例改用 `pg_sandbox(role="none")``TestObservabilityColumns::test_values_round_trip``TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows``test_aclose_idempotent``TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max`
2. `test_schema_has_frozen_columns_in_order``information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")``"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
**测试证据**:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)`),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
**验证**
```
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
```
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
---
## Task 5:其余 fixture 收敛到工厂
- [ ] **文件**`tests/integration/test_postgres_telemetry.py`(修改)
**行为**
1. `legacy_schema``pre_tenant_schema``fresh_schema``partitioned_schema` 改为 `role="none"``least_privilege_dsn``least_privilege_pre_tenant_dsn` 改为 `role="grantee"`
2. 按接口契约,`_LEGACY_DDL``_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
3. `production_template` **不收敛**:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
**测试证据**:这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
**验证**:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
---
## Task 6lint 门与字面量清理
- [ ] **文件**`Makefile`(修改)、`tests/integration/*.py`(注释措辞)
**行为**`lint``check` 各加一步——`tests/` 下命中字面量 `public.llm_calls``exit 1` 并打印命中行。注释与 docstring **同样不豁免**,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls`"。
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
**测试证据**:故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
**验证**
```
make lint && make check
```
---
## Task 7:独立验证(合并前硬门)
- [ ] 派**全新上下文**的 verifier subagent`verification-before-completion`),交给它设计 §11 的判据表逐条核对,重点:
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
- 判据 6:7 条用例迁移前后断言逐条对照
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
---
## Task 8:文档与版本号
- [ ] **文件**`README.md``CHANGELOG.md``pyproject.toml``src/polygateway/__init__.py`
- README`--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls`。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
- CHANGELOG:按设计 §10 如实写明 `tools/``tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
- 版本号两处一致改 `1.3.2`
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
---
## Task 9:发布 1.3.2
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff`)→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow`** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registrytoken 走 `TWINE_PASSWORD`,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
- [ ] 关闭 issue #18,正文指向本计划与设计。
---
## 审查留痕(Codex2026-08-26
报 5 项,**全部采纳**
| # | 意见 | 处置 |
|---|---|---|
| 1 | `ddl`/`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl`/`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table`(测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py`,此时 `fixture 'pg_sandbox' not found` 全红 |
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
## Wiki 注册
```bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ \
--from "plan:2026-08-26-issue18-pg-test-isolation" \
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
.claude/tools/research_wiki.py rebuild_index research-wiki/
```
+30 -3
View File
@@ -1,11 +1,11 @@
--- ---
type: schema type: schema
node_id: schema:llm-calls node_id: schema:llm-calls
title: "表结构: llm_calls(遥测 22 字段)" title: "表结构: llm_calls(遥测 25 字段)"
date: 2026-07-20 date: 2026-07-20
--- ---
# 表结构: llm_calls(遥测 22 字段) # 表结构: llm_calls(遥测 25 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8) ## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
@@ -28,6 +28,9 @@ date: 2026-07-20
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 | | model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 | | sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。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`。见下方口径 |
## usage/成本口径(2026-07-30,est_tokens 解耦) ## usage/成本口径(2026-07-30,est_tokens 解耦)
@@ -54,7 +57,9 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
## 采样参数口径(2026-07-31,issue #4) ## 采样参数口径(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、失败只逐行降级)。 `sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
@@ -77,6 +82,28 @@ SELECT DISTINCT sampling FROM llm_calls
WHERE session_id = $1 AND cache_hit = false AND error IS NULL; 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` 是两回事(前者是那时还没有这一列),跨版本对比须显式区分。
## 埋点位置(单一 helper 铁律) ## 埋点位置(单一 helper 铁律)
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点; - `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
+15 -1
View File
@@ -24,6 +24,13 @@ from polygateway.ocr import OcrClient
from polygateway.pricing import ModelPrice, PricingTable from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
from polygateway.telemetry.schema import telemetry_schema_sql from polygateway.telemetry.schema import telemetry_schema_sql
from polygateway.thinking import (
ThinkingCapability,
ThinkingUnsupportedError,
get_capability,
register_capability,
resolve_thinking,
)
from polygateway.types import ( from polygateway.types import (
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
@@ -32,9 +39,10 @@ from polygateway.types import (
OcrTextResult, OcrTextResult,
SourceConfig, SourceConfig,
TelemetryStatus, TelemetryStatus,
ThinkingObservation,
) )
__version__ = "1.3.0" __version__ = "1.3.2"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
@@ -63,9 +71,15 @@ __all__ = [
"SourceDeadError", "SourceDeadError",
"SourceNotConfiguredError", "SourceNotConfiguredError",
"TelemetryStatus", "TelemetryStatus",
"ThinkingCapability",
"ThinkingObservation",
"ThinkingUnsupportedError",
"TransientError", "TransientError",
"__version__", "__version__",
"gather_bounded", "gather_bounded",
"get_capability",
"register_capability",
"register_provider", "register_provider",
"resolve_thinking",
"telemetry_schema_sql", "telemetry_schema_sql",
] ]
+4 -2
View File
@@ -26,7 +26,7 @@ from polygateway.middleware.structured import StructuredMW
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
from polygateway.ports import TelemetryStatusProvider from polygateway.ports import TelemetryStatusProvider
from polygateway.pricing import PricingTable 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 ( from polygateway.sources import (
AdaptivePacer, AdaptivePacer,
HealthAwareSelector, HealthAwareSelector,
@@ -34,6 +34,7 @@ from polygateway.sources import (
RoundRobinSelector, RoundRobinSelector,
SourceCooldownMemo, SourceCooldownMemo,
) )
from polygateway.thinking import get_capability, resolve_thinking
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
@@ -58,7 +59,8 @@ if TYPE_CHECKING:
TelemetryRecorder, TelemetryRecorder,
Transport, Transport,
) )
from polygateway.providers import ProviderProfile, ThinkingCapability from polygateway.providers import ProviderProfile
from polygateway.thinking import ThinkingCapability
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
RetryPolicy, RetryPolicy,
+31 -1
View File
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from polygateway.types import ChatRequest, LLMResponse from polygateway.types import ChatRequest, LLMResponse, ThinkingObservation
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
@@ -28,6 +28,31 @@ _KEY_PREFIX = "pgw:cache:"
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)} _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 digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。 """多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
@@ -132,6 +157,11 @@ class CacheMW:
data = json.loads(raw) data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS} fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
structured_data = self._rebuild_structured(fields.get("content", ""), request) 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"])
fields.update( fields.update(
cache_hit=True, cache_hit=True,
latency_ms=0, latency_ms=0,
+2
View File
@@ -390,6 +390,8 @@ class RetryMW:
cached_prompt_tokens=result.cached_prompt_tokens, cached_prompt_tokens=result.cached_prompt_tokens,
model_reported=result.model_reported, model_reported=result.model_reported,
reasoning_tokens=result.reasoning_tokens, reasoning_tokens=result.reasoning_tokens,
# 裁定归 transport(它才见得到原始信号),本层只搬运不改判
thinking_observation=result.thinking_observation,
) )
async def _emit( async def _emit(
+44 -2
View File
@@ -23,7 +23,7 @@ from polygateway.errors import (
SourceNotConfiguredError, SourceNotConfiguredError,
) )
from polygateway.middleware.cache import digest_messages from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling from polygateway.types import ThinkingObservation, canonical_sampling_json, merge_sampling
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
@@ -55,6 +55,31 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False) 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 _cap_text(text: str, cap: int | None) -> str: def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap: if cap is None or len(text) <= cap:
@@ -111,6 +136,9 @@ class _AttemptUsage:
cached_prompt_tokens: int | None = None cached_prompt_tokens: int | None = None
model_reported: str | None = None model_reported: str | None = None
reasoning_tokens: int | None = None reasoning_tokens: int | None = None
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
@classmethod @classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage: def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -128,11 +156,12 @@ class _AttemptUsage:
cached_prompt_tokens=response.cached_prompt_tokens, cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported, model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens, reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
) )
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。"""
def __init__( def __init__(
self, self,
@@ -185,6 +214,7 @@ class TelemetryEmitter:
cached_prompt_tokens=usage.cached_prompt_tokens, cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported, model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens, reasoning_tokens=usage.reasoning_tokens,
thinking_observation=usage.thinking_observation,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
@@ -214,6 +244,8 @@ class TelemetryEmitter:
cached_prompt_tokens=response.cached_prompt_tokens, cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported, model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens, reasoning_tokens=response.reasoning_tokens,
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
thinking_observation=response.thinking_observation,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损: # 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同 # sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling), sampling=canonical_sampling_json(request.sampling),
@@ -247,6 +279,8 @@ class TelemetryEmitter:
cached_prompt_tokens=None, cached_prompt_tokens=None,
model_reported=None, model_reported=None,
reasoning_tokens=None, reasoning_tokens=None,
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
thinking_observation=ThinkingObservation.UNKNOWN,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D) # 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling), sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的 # 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
@@ -276,6 +310,10 @@ class TelemetryEmitter:
model_reported: str | None, model_reported: str | None,
sampling: str | None, sampling: str | None,
reasoning_tokens: int | None, reasoning_tokens: int | None,
# issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。
# 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation`
# 仍按外部输入防御——违约的代价不该是丢掉整行遥测
thinking_observation: ThinkingObservation,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None, tenant_id: str | None,
meta: Mapping[str, Any], meta: Mapping[str, Any],
@@ -328,6 +366,10 @@ class TelemetryEmitter:
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行 # 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "", tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta), meta=_canonical_meta_json(meta),
# 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不
# 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让
# Postgres 那一路悄悄少一列数据
thinking_observation=_normalize_observation(thinking_observation),
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+5 -1
View File
@@ -260,13 +260,16 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。 """遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id` `tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。 的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由 recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。
""" """
@@ -298,4 +301,5 @@ class TelemetryRecorder(Protocol):
reasoning_tokens: int | None, reasoning_tokens: int | None,
tenant_id: str, tenant_id: str,
meta: str, meta: str,
thinking_observation: str,
) -> None: ... ) -> None: ...
+6 -142
View File
@@ -3,6 +3,9 @@
每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字 每个 provider 显式声明 thinking 参数注入形态与响应处理差异;查找按名字
**精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享 **精确匹配**,未注册即装配期报错。注册是纯函数——返回新表,不修改共享
状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。 状态(纯 asyncio 中立铁律);client 经 `registry` 参数持有自己的表。
**本模块只存放声明,不做判断**: 拿这些声明去决定注入什么、响应算不算推理,
全部在 `thinking.py`(P7 决策逻辑与状态存储分离)。
""" """
from collections.abc import Mapping from collections.abc import Mapping
@@ -10,8 +13,6 @@ from dataclasses import dataclass
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
from loguru import logger
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderProfile: class ProviderProfile:
@@ -71,8 +72,9 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
strip_think_tags=False, strip_think_tags=False,
), ),
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2), # 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被 # 2026-08-25 复测结论不变(findings 2026-08-25 §5);**直连官方端点未验证**。
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。 # 实测 enable_thinking / thinking 两种写法均被静默丢弃(prompt_tokens
# 恒定等于基线 194),reasoning_effort 才是真开关——本片段的选型据此成立。
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的 # "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近 # thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。 # "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
@@ -87,144 +89,6 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
) )
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( def get_provider(
name: str, *, registry: Mapping[str, ProviderProfile] | None = None name: str, *, registry: Mapping[str, ProviderProfile] | None = None
) -> ProviderProfile: ) -> ProviderProfile:
+12 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列" 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 + `DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 +
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。 `created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。 互不依赖(import-linter 契约执法)。
@@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}' meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT
); );
""" """
@@ -80,7 +81,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT
); );
""" """
@@ -95,6 +97,9 @@ SQLITE_BACKFILL = (
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。 # ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"), ("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"), ("meta", "TEXT NOT NULL DEFAULT '{}'"),
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
("thinking_observation", "TEXT"),
) )
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -107,6 +112,8 @@ _PG_BACKFILL_DECLS = (
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级 # 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
("tenant_id", "TEXT NOT NULL DEFAULT ''"), ("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"), ("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
# 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"),
) )
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -143,6 +150,7 @@ COLUMNS = (
"reasoning_tokens", "reasoning_tokens",
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation",
) )
_COLUMN_SET = frozenset(COLUMNS) _COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。 """写一行遥测;字段集合即 25 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。 占位符同序——两者必须一起改,分开改就是把值写进错位的列。
+247
View File
@@ -0,0 +1,247 @@
"""推理这件事的全部**决策**: 请求侧注入形态、响应侧结果裁定、二者的对账。
与 `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
from polygateway.types import ThinkingObservation
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:
"""某个**具体模型**能否关闭推理(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 稳定关闭,零跳变;"
"2026-08-25 复测依然成立(prompt 194 = 基线、completion 3、无推理正文)。"
"两条限制(findings 2026-08-25-thinking-observability-regression §3.1/§5): "
"① 非流式路径观测不到推理信号——推理已计费,但正文与 usage 明细都不回传;"
"② enable_thinking / thinking:{type:enabled} 对本模型无效,仅 reasoning_effort 是真开关"
),
),
"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 reconcile_thinking(
*,
enable_thinking: bool | None,
observation: ThinkingObservation,
capability: ThinkingCapability | None,
model: str,
) -> str | None:
"""把静态声明与运行时观测对账;矛盾返回告警文案,无矛盾返回 None。
能力表过期是必然事件(M3 的 evidence 曾停在 8-02 整整 23 天),而过期的
表现是静默错觉。本函数把它变成可报警事件,代价是一次枚举比较。
**只判定、不打日志**: 文案作为返回值交给调用点,单测才能直接断言告警内容,
而不必去解析日志格式;节流也才能留在握有实例状态的 transport 里。
**不抛错**: 一次观测不足以否决一次成功的调用;可观测性属遥测方向,降级即
warning(P5 的"报错而非放行"只约束限流/熔断)。矛盾结果已随 `LLMResponse`
与遥测落地,处置权归下游。
"""
# Phase 1: 调用方不表态 —— 没提要求就无从谈"违背"
if enable_thinking is None:
return None
# Phase 2: 要求关闭 —— 只有 OBSERVED 能证伪。UNKNOWN 没有证伪力,拿它报警
# 等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警
if enable_thinking is False:
if observation is not ThinkingObservation.OBSERVED:
return None
return _off_but_observed(model, capability)
# Phase 3: 要求开启 —— ABSENT 是正面证伪,UNKNOWN 是"看不见",两者文案不可混
if observation is ThinkingObservation.ABSENT:
return (
f"模型 {model!r} 的 enable_thinking=True 未生效: 已注入开启参数,"
f"上游却明确上报本次未推理(reasoning_tokens=0)"
)
if observation is ThinkingObservation.UNKNOWN:
return (
f"模型 {model!r} 的 enable_thinking=True 无法确认是否生效: 已注入开启参数,"
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} 的 enable_thinking=False 未被满足: 实测观测到推理发生,"
f"且该模型的推理能力尚未登记(本次按 provider 形态尽力注入)。"
f"请实测后用 register_capability 登记其真实能力"
)
return (
f"模型 {model!r} 的 enable_thinking=False 未被满足: 实测观测到推理发生,"
f"而能力表登记 can_disable={capability.can_disable}(evidence: {capability.evidence})。"
f"能力表可能已过期——请复测后用 register_capability 更新登记"
)
+63 -8
View File
@@ -14,6 +14,7 @@ import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import httpx import httpx
from loguru import logger
from polygateway.errors import ( from polygateway.errors import (
PolyGatewayError, PolyGatewayError,
@@ -22,15 +23,16 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.providers import ( from polygateway.providers import ProviderProfile, get_provider
ProviderProfile, from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.thinking import (
ThinkingCapability, ThinkingCapability,
ThinkingUnsupportedError, ThinkingUnsupportedError,
get_capability, get_capability,
get_provider, observe_thinking,
reconcile_thinking,
resolve_thinking, resolve_thinking,
) )
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.transports._http_errors import compose_message, summarize_body from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
@@ -320,6 +322,12 @@ class OpenAICompatTransport:
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。 # 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律 # 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
self._warned_models: set[str] = set() self._warned_models: set[str] = set()
# 对账告警独立节流,**不复用** `_warned_models`: 两者语义不同(那个 set 记
# 的是"未登记能力已告警过",这个记的是"某源某方向的矛盾已告警过"),共用
# 一个容器会让两种告警的生命周期纠缠在一起——将来任一侧想加清空/过期策略,
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, bool | None]] = set()
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {} self._clients: dict[str, httpx.AsyncClient] = {}
@@ -390,8 +398,9 @@ class OpenAICompatTransport:
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
try: try:
if stream: if stream:
return await self._complete_stream(client, url, payload, source, profile) result = await self._complete_stream(client, url, payload, source, profile)
return await self._complete_once(client, url, payload, source, profile) else:
result = await self._complete_once(client, url, payload, source, profile)
except StreamLivenessTimeout as exc: except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
@@ -399,6 +408,40 @@ class OpenAICompatTransport:
except httpx.TransportError as exc: except httpx.TransportError as exc:
# VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8) # VT 宽集: 覆盖断连/协议错误/读写失败(设计 §9 行 8)
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
# 此处是唯一同时握有请求方向与响应结果的地方,对账只能落在这里
self._warn_on_thinking_mismatch(source, result)
return result
def _warn_on_thinking_mismatch(self, source: SourceConfig, result: TransportResult) -> None:
"""声明与观测矛盾即 warning;按 (source, model, direction) 节流,同组合只喊一次。
三段缺一不可。**方向**: 同一模型的开、关两档是两个独立的矛盾。**源名**:
多源多账号是本库的核心场景,同一 model 跨 N 个源是常态,而每个源背后是
独立的账号/网关,一个源的行为不代表另一个——漏掉源名,5 个源里第一个出
问题的喊完一次,其余四个永久静音。逐次调用刷屏会把告警变成噪声,噪声等于
没有告警。
**先判键再对账**: `reconcile_thinking` 会拼含完整 `evidence` 的长字符串,
而非流式档每次调用都命中这一分支,节流后再拼是纯粹的热路径浪费。
"""
key = (source.name, source.model, source.enable_thinking)
if key in self._warned_mismatches:
return
message = reconcile_thinking(
enable_thinking=source.enable_thinking,
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( async def embed(
self, *, texts: list[str], source: SourceConfig, call_id: str self, *, texts: list[str], source: SourceConfig, call_id: str
@@ -460,6 +503,7 @@ class OpenAICompatTransport:
content, thinking = self._finalize_text(content_parts, thinking_parts, profile) content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source) self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged) prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
@@ -471,7 +515,12 @@ class OpenAICompatTransport:
raw={"usage": sink.get("usage")}, raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")), 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( def _check_done(
@@ -545,6 +594,7 @@ class OpenAICompatTransport:
) )
self._reject_empty_completion(content, source) self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}) prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
@@ -556,7 +606,12 @@ class OpenAICompatTransport:
raw={"usage": body.get("usage")}, raw={"usage": body.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")), cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
model_reported=_coerce_model_reported(body.get("model")), 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: async def aclose(self) -> None:
+42 -1
View File
@@ -10,6 +10,7 @@ import math
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
@@ -166,6 +167,27 @@ def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False) return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
class 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) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -202,7 +224,21 @@ class LLMResponse:
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈 `completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`, 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)。"""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -299,6 +335,11 @@ class TransportResult:
cached_prompt_tokens: int | None = None cached_prompt_tokens: int | None = None
model_reported: str | None = None model_reported: str | None = None
reasoning_tokens: int | None = None reasoning_tokens: int | None = None
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
"""本次调用"推理是否真的发生"的裁定(issue #16/#17),由 transport 组装时填。
默认 `UNKNOWN` 而非 `ABSENT`: 不做裁定的 transport(OCR/embedding 等)沉默
时,不该替上游做出"没推理"这个它从未做过的声明。"""
@dataclass(frozen=True) @dataclass(frozen=True)
+123 -47
View File
@@ -1,21 +1,27 @@
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。 """真实 API 验证推理开关与推理可观测性(issue #5 + #6;判据于 #16/#17 重建)。
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径, 本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
验证不了"这个参数在这个模型上到底关没关掉推理" 验证不了"这个参数在这个模型上到底关没关掉推理"
条判据纪律(来自 findings §4c 的实测教训): 条判据纪律(第 1、2 条来自 findings §4c,第 1 条的推翻与第 3 条来自
`findings/2026-08-25-thinking-observability-regression.md`):
1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出 1. **判别量是库裁定的三态 `thinking_observation`,既不是 `reasoning_tokens`
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文), 也不是 `completion_tokens`。** 长度判据早已排除: 两档的输出长度分布**是
开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。 重叠的**(实测关闭档最高 46 token、开启档最低 13 token),按阈值判两边都会
而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None, 误判。而 `reasoning_tokens` 这个曾经"干净分开"的判据也已失效——MiniMax
开启 15/15 大于 0。 这一路上游不再返回 `usage.completion_tokens_details`,该字段恒 `None`;同一
2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的 次调用里库明明拿得到 185 字符推理正文,单看 token 计数却把"推理正常"读成
"没推理"(2026-08-25 findings §3.4/结论③,四条用例因此假红)。三态裁定同时
看正文与计数: **正文是事实本身,token 计数只是对事实的转述**。
2. **另配一个不含魔数的确定性锚点**(见 L2b、L5): 同一模型上,关闭档的
`prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧 `prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧
token 数随之变大。这是相对比较,不硬编码任何具体数值 token 数随之变大。这是相对比较,不硬编码任何具体数值;且它不依赖上游是否
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回 回传推理正文,所以在"观测不到推理"的非流式路径上依然作数。
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c), 3. **`UNKNOWN` 不等于"没推理",不能拿它判红。** 关闭方向要求每轮"未观测到
开启方向因此可能偶尔观测不到;关闭方向不受影响。 推理"(`UNKNOWN` 计入满足——它没有证伪力),其证伪力来自: 模型若偷偷推理了,
可观测路径会翻成 `OBSERVED`。开启方向只要求多数轮 `OBSERVED`;M3 非流式
路径整片观测不到,该档由 L5 用另一套断言覆盖。
源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。 源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。
""" """
@@ -30,22 +36,23 @@ from pathlib import Path
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings from polygateway import GatewayClient, GatewaySettings, ThinkingObservation
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
RequestRejectedError, RequestRejectedError,
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability from polygateway.thinking import DEFAULT_CAPABILITIES, get_capability
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} _ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV) _HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# slow: 本组 137 次真实调用、约 7钟,且判据是统计性的——网络抖动会让它偶发 # slow: 本组 92 次真实调用、约 4半(2026-08-26 判据换三态后实测;此前记的
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试 # "137 次、约 7 分钟"已被证伪,别照旧值估 CI 预算),且判据是统计性的——网络抖动
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并 # 会让它偶发失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci`
# 存档报告。"不自动门控"不等于"可跳过"。 # 会把测试变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow`
# 显式真跑并存档报告。"不自动门控"不等于"可跳过"。
pytestmark = [ pytestmark = [
pytest.mark.slow, pytest.mark.slow,
pytest.mark.skipif( pytest.mark.skipif(
@@ -60,9 +67,6 @@ _ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里 # 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。" _PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
_ON_MIN_COMPLETION = 100
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
_ROWS: list[dict] = [] _ROWS: list[dict] = []
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。 # 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
@@ -107,6 +111,10 @@ async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -
"prompt_tokens": resp.prompt_tokens, "prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens, "completion_tokens": resp.completion_tokens,
"reasoning_tokens": resp.reasoning_tokens, "reasoning_tokens": resp.reasoning_tokens,
# 结论与证据一起入报告: 只记 observation 会让"为什么这么判"
# 不可复核,而 thinking_chars 正是本次改判的直接证据
"thinking_observation": resp.thinking_observation,
"thinking_chars": len(resp.thinking),
"content": resp.content[:60], "content": resp.content[:60],
} }
) )
@@ -128,26 +136,32 @@ def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -
def _reasoning_off(obs: dict) -> bool: def _reasoning_off(obs: dict) -> bool:
"""关闭方向: 只看 reasoning_tokens """关闭方向: 只要没观测到推理即算满足
`UNKNOWN` 计入满足是有意的: 它没有证伪力(本次无任何信号,判不出来),拿它
判红等于每次关闭调用都喊一遍。本判据真正的证伪力在于——模型若偷偷推理了,
可观测路径会把裁定翻成 `OBSERVED`。
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做 **刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会 "只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会
把这种正常波动误判成"没关掉" 把这种正常波动误判成"没关掉"
""" """
return obs["reasoning_tokens"] in (None, 0) return obs["thinking_observation"] != ThinkingObservation.OBSERVED
def _reasoning_on(obs: dict) -> bool: def _reasoning_on(obs: dict) -> bool:
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据 """开启方向: 观测到推理即为真
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大 判据从 `reasoning_tokens` 换成库的三态裁定,因为 MiniMax 这一路已不再上报
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。 `completion_tokens_details`(2026-08-25 findings 结论②),该字段恒 `None`;
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。 而库在同一次调用里拿得到 185 字符推理正文(findings §3.4)——旧判据看不见
它,L2/L3b/L4/L5 四条因此假红。
也不能退回 completion_tokens 当判据: medium 档的推理量方差极大(实测 15 轮
跨 7-170 token),两档分布还与关闭档重叠,按长度阈值判会把"推理了但想得少"
误判成没推理。
""" """
reasoning = obs["reasoning_tokens"] return obs["thinking_observation"] == ThinkingObservation.OBSERVED
if reasoning is not None:
return reasoning > 0
return obs["completion_tokens"] > _ON_MIN_COMPLETION
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str): def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
@@ -163,14 +177,18 @@ def _write_report():
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"test_thinking_live_{ts}.md" path = _OUT_DIR / f"test_thinking_live_{ts}.md"
lines = [ lines = [
"# 推理开关与 reasoning_tokens 真实 API 验证", "# 推理开关与推理可观测性真实 API 验证",
"", "",
f"- 时间: {ts}", f"- 时间: {ts}",
f"- 每档轮数: {_ROUNDS}", f"- 每档轮数: {_ROUNDS}",
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限" "- 判别量: 库裁定的三态 `thinking_observation`(OBSERVED/ABSENT/UNKNOWN),"
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)", "由推理正文与 reasoning_tokens 共同裁定 —— 正文是事实,token 计数只是转述",
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})", "- 关闭判据: **轮** observation != OBSERVED(UNKNOWN 计入满足,它没有证伪力);"
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数", "刻意不设输出长度上限(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
"- 开启判据: **多数轮** observation == OBSERVED",
"- 确定性锚点(L2b、L5): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
"- L5(非流式): M3 该路径推理已计费却不回传正文,故不断言「观测到推理」,"
"改断锚点可分 + 开启档不被误判为 ABSENT",
"", "",
"## 矩阵结论", "## 矩阵结论",
"", "",
@@ -206,7 +224,7 @@ class TestMiniMaxM3:
"L1", "L1",
"enable_thinking=False(流式)", "enable_thinking=False(流式)",
"PASS" if len(offs) == len(obs) else "FAIL", "PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理", f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs, obs,
) )
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}" assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
@@ -256,7 +274,7 @@ class TestMiniMaxM3:
"L3", "L3",
"enable_thinking=None(不干预,基线)", "enable_thinking=None(不干预,基线)",
"PASS" if len(quiet) == len(obs) else "FAIL", "PASS" if len(quiet) == len(obs) else "FAIL",
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)", f"{len(quiet)}/{len(obs)} 轮未观测到推理(M3 默认档本就不推理)",
obs, obs,
) )
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}" assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
@@ -271,6 +289,12 @@ class TestMiniMaxM3:
判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入" 判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入"
一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。 一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。 既然非法值与 `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) rounds = max(3, _ROUNDS // 3)
bogus = await _run_rounds( bogus = await _run_rounds(
@@ -318,23 +342,50 @@ class TestMiniMaxM3:
) )
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}" 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) rounds = max(3, _ROUNDS // 2)
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False) 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) on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
offs = [o for o in off if _reasoning_off(o)] offs = [o for o in off if _reasoning_off(o)]
ons = [o for o in on if _reasoning_on(o)] off_max = max(o["prompt_tokens"] for o in off)
ok = len(offs) == len(off) and len(ons) * 2 > len(on) 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( _record(
"L5", "L5",
"非流式路径重跑 L1/L2", "非流式: prompt 锚点可分 + 开启档如实标 UNKNOWN 而非 ABSENT",
"PASS" if ok else "FAIL", "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, off + on,
) )
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}" 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: class TestOtherProviders:
@@ -357,11 +408,36 @@ class TestOtherProviders:
matrix, matrix,
desc, desc,
"PASS" if len(offs) == len(obs) else "FAIL", "PASS" if len(offs) == len(obs) else "FAIL",
f"{len(offs)}/{len(obs)}确认未推理", f"{len(offs)}/{len(obs)} 轮未观测到推理",
obs, obs,
) )
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {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: class TestCapabilityDrift:
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。""" """L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
@@ -395,7 +471,7 @@ class TestCapabilityDrift:
"L8", "L8",
desc, desc,
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)", "PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭", f"实测未观测到推理 {dict(verdict)}(True=满足);声明 can_disable=True 要求每轮满足",
obs, obs,
) )
assert len(offs) == len(obs), ( assert len(offs) == len(obs), (
+248
View File
@@ -0,0 +1,248 @@
"""PG 集成测试的一次性沙箱工厂(issue #18)。
**为什么把它收敛成一份**: 在此之前,"建临时 schema → 挂 search_path → teardown
删净"这套样板在两个测试文件里重复了七处,清理逻辑各写各的——任何一处写漏,残留都
落在与真实批跑共用的那个库上。工厂让清理只有一份实现,并让"用例拿不到管理连接"
成为结构事实而不是纪律。
**admin DSN 不做成 fixture**: 它能对共享表执行任何语句。做成 fixture 等于把这个
能力摆在每一条用例面前,"用例不该直接用"就只是一句提醒。故它是模块私有函数,
只被工厂内部调用,`PgSandbox` 也不携带它。
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from uuid import uuid4
import pytest
from dotenv import dotenv_values
if TYPE_CHECKING:
from collections.abc import Sequence
# 测试专用口令: 这些角色只在单条用例的生命周期内存在,且只对自建 schema 有权。
# 它不是机密,写死在这里比走 .env 更清楚——.env 里的每一项都该是真实部署会用的。
_SANDBOX_PASSWORD = "pgw-sandbox-not-a-secret" # noqa: S105
_Role = Literal["none", "owner", "grantee"]
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str
"""已挂 `options=-csearch_path=<schema>`,用例默认用它。"""
bare_dsn: str | None
"""同角色但**不挂** search_path(回落 `"$user", public`);`role="none"` 时为 None。"""
def _admin_dsn() -> str | None:
"""读 `.env` 的 `PGW_TELEMETRY_PG_DSN` 并剥掉 SQLAlchemy 风格的 `+driver` 后缀。"""
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _require_admin_dsn() -> str:
"""取管理连接串;未配置则 skip,连错库则 fail(不是 skip)。
库名守卫不肯降级成 skip: 这个实例上还有 app/chs_prod 等在用库,把"连错库"
悄悄跳过,等于让一次配置事故以"没跑那些测试"的形态过关。
"""
value = _admin_dsn()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"PG 集成测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
def _with_search_path(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _as_role(dsn: str, role: str) -> str:
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
.pgpass / trust)与 `postgresql:///db?host=/var/run/postgresql`(unix socket)
都是合法 DSN,却没有可替换的内联凭据段。静默返回原串的后果不是测试报错,而是
沙箱以**管理身份**建成、用例照常绿,同时 `bare_dsn` 变成超级用户连接——最坏
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红。
这正是 P5"严禁默认值掩盖错误"要挡的形态。
"""
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
if count != 1:
raise RuntimeError(
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
)
return swapped
@pytest.fixture
async def pg_catalog_probe():
"""只读地查 PG catalog,**仅供工厂自测核对残留**,不是通用查询入口。
它拿的是管理连接,故有意只暴露给 `test_pg_sandbox.py` 这一类"验证隔离本身
是否成立"的用例;业务断言一律走 `PgSandbox.dsn`。
"""
import asyncpg
dsn = _require_admin_dsn()
async def probe(sql: str, *args: object) -> list[tuple]:
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
# 管理能力"这句话降格成一句 docstring 里的请求。
if not sql.lstrip().upper().startswith("SELECT"):
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
conn = await asyncpg.connect(dsn, timeout=10)
try:
return [tuple(r) for r in await conn.fetch(sql, *args)]
finally:
await conn.close()
return probe
@pytest.fixture
async def pg_sandbox():
"""一次性沙箱工厂: `await pg_sandbox(ddl=..., role=...)`,清理由 fixture 兜底。
同一条用例可以要多个沙箱(如"A 的角色去动 B 的表"),它们按后进先出清理。
"""
import asyncpg
admin_dsn = _require_admin_dsn()
# 清理动作栈: 每建成一个对象就入栈一条,setup 中途失败与正常 teardown 共用
# 同一条退栈路径——两处各写一份的话,失败那条永远是没被测过的那份。
cleanups: list[str] = []
async def _run_as_admin(*statements: str) -> None:
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
for statement in statements:
await conn.execute(statement)
finally:
await conn.close()
async def _unwind(statements: list[str]) -> None:
"""逆序执行清理并**逐条容错**: 一条失败不该拖累其余对象的清理。
吞掉异常是不行的(残留会静默累积),但让第一条失败中断整栈更糟——角色是
全局对象,漏掉的每一个都要人手工去删。故全部试完再抛出第一个异常。
"""
first: BaseException | None = None
for statement in reversed(statements):
try:
await _run_as_admin(statement)
except Exception as exc: # noqa: BLE001 — 见 docstring: 收集而非吞没
first = first or exc
statements.clear()
if first is not None:
raise first
async def make(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: _Role = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox:
# 权限门在建任何对象**之前**: pytest.skip 抛的是 BaseException,若它在
# 已建对象之后触发,清理会去 DROP 从未建成的东西并把 skip 盖掉。
if role != "none":
conn = await asyncpg.connect(admin_dsn, timeout=10)
try:
can_create = await conn.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
)
finally:
await conn.close()
if not can_create:
pytest.skip("当前账号无权建临时角色,跳过需要独占角色的用例")
# schema 与角色的前缀有意不同: 同名会让 "$user" 命中自有 schema 并遮蔽
# 共享表,于是"search_path 落到共享表"这个最坏情况就再也构造不出来。
suffix = uuid4().hex[:12]
schema = f"pgw_s_{suffix}"
role_name = f"pgw_r_{suffix}" if role != "none" else None
# 本次调用自己的清理栈: 失败只回滚**本次**建成的对象。同一条用例常要两个
# 沙箱(如"A 的角色去动 B 的表"),回滚整栈会把已通过断言依赖的对象也删掉。
local: list[str] = []
try:
if role_name is not None:
await _run_as_admin(f"CREATE ROLE {role_name} LOGIN PASSWORD '{_SANDBOX_PASSWORD}'")
# DROP OWNED BY 必须排在 DROP ROLE 之前: 角色仍持有对象时删不掉
local.append(f"DROP ROLE IF EXISTS {role_name}")
local.append(f"DROP OWNED BY {role_name}")
owner_clause = f" AUTHORIZATION {role_name}" if role == "owner" else ""
await _run_as_admin(f"CREATE SCHEMA {schema}{owner_clause}")
local.append(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
bare = _as_role(admin_dsn, role_name) if role_name is not None else None
# role="owner" 时 DDL 由角色自己执行,表属主才会是它;"grantee" 的现场
# 恰恰相反——表由别的账号建好,角色只拿到表级权限。
ddl_dsn = _with_search_path(bare if role == "owner" else admin_dsn, schema)
if ddl is not None:
conn = await asyncpg.connect(ddl_dsn, timeout=10)
try:
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
if role == "grantee":
await _run_as_admin(f"GRANT USAGE ON SCHEMA {schema} TO {role_name}")
if ddl is not None:
await _run_as_admin(
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
)
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
used = bare if role_name is not None else admin_dsn
sandbox = PgSandbox(
schema=schema,
role=role_name,
dsn=_with_search_path(used, schema),
bare_dsn=bare,
)
if role_name is not None:
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
# IF EXISTS,第二遍必报错)。
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
actual = await conn.fetchval("SELECT current_user")
finally:
await conn.close()
if actual != role_name:
raise RuntimeError(
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
"权限边界不成立,拒绝把这个沙箱交出去。"
)
except BaseException:
await _unwind(local)
raise
cleanups.extend(local)
return sandbox
yield make
await _unwind(cleanups)
+201
View File
@@ -0,0 +1,201 @@
"""`conftest.py` 沙箱工厂自身的行为测试(issue #18 Task 1)。
工厂是本次一切隔离的地基: 它若在 setup 中途失败时漏掉清理、或让角色名与
schema 名撞上,受害的不是这一个文件,而是此后每一条 PG 用例。故它必须先被测。
**这里的断言全部只看自建对象与 PG catalog**,不读任何共享数据。
"""
from __future__ import annotations
import pytest
from tests.integration.conftest import _as_role
_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
@pytest.fixture
async def assert_no_leftovers(pg_catalog_probe):
"""收集沙箱名,在 `pg_sandbox` 清理之后回查它们是否真的没了。
必须比 `pg_sandbox` **先** setup: pytest 的 finalizer 是后进先出,先 setup
的后 teardown——本 fixture 的检查因此发生在沙箱清理之后,而不是之前。
"""
seen: list[tuple[str, str | None]] = []
yield seen
for schema, role in seen:
left = await pg_catalog_probe("SELECT nspname FROM pg_namespace WHERE nspname = $1", schema)
assert left == [], f"沙箱 schema 未清理: {schema}"
if role is not None:
left = await pg_catalog_probe("SELECT rolname FROM pg_roles WHERE rolname = $1", role)
assert left == [], f"沙箱角色未清理: {role}"
async def _oid_of_llm_calls(dsn: str) -> int | None:
import asyncpg
conn = await asyncpg.connect(dsn, timeout=10)
try:
return await conn.fetchval("SELECT to_regclass('llm_calls')::oid")
finally:
await conn.close()
class TestRoleDsnConstruction:
"""凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。
这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱
"看起来"建好了、用例照常绿,而 `bare_dsn` 其实是超级用户——最坏情况用例
会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红。
行已经没了。设计 §5.1 要的是"越界做不到",不是"越界会被发现"
"""
def test_inline_credentials_are_replaced(self):
swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x")
assert swapped.startswith("postgresql://pgw_r_x:")
assert "app:secret" not in swapped
@pytest.mark.parametrize(
"dsn",
[
"postgresql://h:5432/polygateway", # 口令走 PGPASSWORD / .pgpass / trust
"postgresql:///polygateway?host=/var/run/postgresql", # unix socket
],
)
def test_a_dsn_without_inline_credentials_is_refused(self, dsn):
"""这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。"""
with pytest.raises(RuntimeError, match="沙箱角色"):
_as_role(dsn, "pgw_r_x")
class TestCatalogProbeIsReadOnly:
"""探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。"""
async def test_non_select_statements_are_refused(self, pg_catalog_probe):
with pytest.raises(RuntimeError, match="只接受 SELECT"):
await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'")
class TestSchemaOnlySandbox:
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
sandbox = await pg_sandbox(ddl=_DDL)
import asyncpg
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
where = await conn.fetchval(
"SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass('llm_calls')"
)
finally:
await conn.close()
assert where == sandbox.schema
assert sandbox.role is None
assert sandbox.bare_dsn is None
class TestOwnerRoleSandbox:
async def test_the_role_owns_its_own_table(self, pg_sandbox):
"""`role="owner"`: 表由角色自己建,故属主是它——与"用维护角色跑"的现场一致。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
import asyncpg
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
owner = await conn.fetchval(
"SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = to_regclass('llm_calls')"
)
finally:
await conn.close()
assert owner == sandbox.role
# 名字必须错开: 同名会让 "$user" 命中自有 schema 并遮蔽真表,
# 最坏情况用例就再也走不到那条真实路径上(设计 §5.1 实测)
assert sandbox.role != sandbox.schema
assert not sandbox.role.startswith("pgw_s_")
assert not sandbox.schema.startswith("pgw_r_")
async def test_bare_dsn_falls_through_to_the_default_search_path(self, pg_sandbox):
"""裸 DSN 必须真的回落到 `"$user", public`——最坏情况用例全靠它构造现场。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
import asyncpg
conn = await asyncpg.connect(sandbox.bare_dsn, timeout=10)
try:
path = await conn.fetchval("SHOW search_path")
finally:
await conn.close()
assert path == '"$user", public'
# 裸 DSN 解析到的绝不能是沙箱里那张表(否则"落到共享表"的现场是假的)
assert await _oid_of_llm_calls(sandbox.bare_dsn) != await _oid_of_llm_calls(sandbox.dsn)
class TestGranteeRoleSandbox:
async def test_grantee_can_write_but_cannot_create(self, pg_sandbox):
"""`role="grantee"`: 表属主是 admin,角色只拿表级权限——最小权限部署的现场。"""
import asyncpg
sandbox = await pg_sandbox(ddl=_DDL, role="grantee")
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
try:
await conn.execute("INSERT INTO llm_calls (call_id) VALUES ('g1')")
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 1
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("CREATE TABLE another (x TEXT)")
finally:
await conn.close()
class TestCleanup:
async def test_setup_failure_leaves_nothing_behind(self, pg_sandbox, pg_catalog_probe):
"""建到一半失败时也必须删净——角色是**全局**对象,残留不随库消失。"""
before_schemas = await pg_catalog_probe(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
)
before_roles = await pg_catalog_probe(
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
)
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
assert (
await pg_catalog_probe(
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
)
== before_schemas
)
assert (
await pg_catalog_probe(
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
)
== before_roles
)
async def test_a_failure_does_not_roll_back_earlier_sandboxes(self, pg_sandbox):
"""一次失败只回滚它自己建的东西——同一条用例里先建成的沙箱必须毫发无损。
"A 的角色去动 B 的表"这类用例一条要两个沙箱;若失败回滚把整栈清空,受害的
是那些**已经通过**的断言所依赖的对象,而症状会以"表不见了"的形态出现在
与真因无关的地方。
"""
good = await pg_sandbox(ddl=_DDL, role="owner")
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
assert await _oid_of_llm_calls(good.dsn) is not None, "先前建成的沙箱被误清理"
async def test_teardown_removes_schema_and_role(self, assert_no_leftovers, pg_sandbox):
"""正常路径的清理: 断言发生在 `pg_sandbox` teardown **之后**(见 fixture 说明)。"""
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
assert_no_leftovers.append((sandbox.schema, sandbox.role))
+218 -275
View File
@@ -1,11 +1,15 @@
"""PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。 """PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod 等 DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod 等
在用库——本测试只允许连 polygateway 专用库(fixture 里守卫)。 在用库——本测试只允许连 polygateway 专用库(`conftest.py` 的工厂里守卫)。
隔离纪律(M4 事故教训): `llm_calls` 是与真实批跑/迁移项目共享的表, 隔离纪律(issue #18): 本文件对共享表 `llm_calls` **零触碰**——每条用例都在
**严禁 DROP/TRUNCATE**——本测试以 run 级 call_id 前缀隔离,断言只看 `pg_sandbox` 建的一次性 schema 里跑,建/删都只发生在自己的 schema 内。
自己写入的行,teardown 只删自己的行。 此前那套 run 级 call_id 前缀隔离已随之删除: schema 隔离完全取代了它,
两套并存只会让"这一行归谁"重新变成需要论证的事。
**唯一的例外是连接**: 连接是实例级共享资源,schema 隔离对它无效,故
`TestPoolFootprint` 仍靠一个就地生成的唯一 `application_name` 认领本池连接。
""" """
from __future__ import annotations from __future__ import annotations
@@ -52,17 +56,12 @@ _EXPECTED_COLUMNS = [
"reasoning_tokens", "reasoning_tokens",
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation",
] ]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
_RUN_PREFIX = f"pgwtest-{uuid4().hex[:8]}"
def _cid(suffix: str) -> str:
return f"{_RUN_PREFIX}-{suffix}"
def _dsn() -> str | None: def _dsn() -> str | None:
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
merged = {**dotenv_values(".env"), **os.environ} merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN") raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw: if not raw:
@@ -72,23 +71,24 @@ def _dsn() -> str | None:
@pytest.fixture @pytest.fixture
async def dsn(): async def template_admin_dsn() -> str:
"""管理连接串,**只服务 `production_template` 一个 fixture**。
它没有随其余六个 fixture 一起收敛到 `pg_sandbox`,是因为 `production_template`
要自建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期
清理都是它自己的(设计 §7.1 末段),工厂强行接管会把这些语义压扁。
名字不叫 `dsn`: 叫 `dsn` 等于把一个能动共享表的连接摆在每条用例的参数位上,
而设计 §7.1 约束 3 要的正是"用例拿不到管理连接"。此处的窄命名是那条约束在
本文件能做到的最接近的形态。
"""
value = _dsn() value = _dsn()
if value is None: if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置") pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库 # 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"): if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}") pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
yield value return value
# teardown: 只删本 run 写入的行;表可能尚不存在(全新库)则忽略
import asyncpg
conn = await asyncpg.connect(value, timeout=10)
try:
if await conn.fetchval("SELECT to_regclass('llm_calls')") is not None:
await conn.execute("DELETE FROM llm_calls WHERE call_id LIKE $1", f"{_RUN_PREFIX}-%")
finally:
await conn.close()
async def _record_minimal( async def _record_minimal(
@@ -100,7 +100,7 @@ async def _record_minimal(
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证。 "库写错列位"的形态误报,而漏抄的列则悄悄不被验证。
""" """
fields: dict[str, object] = { fields: dict[str, object] = {
"call_id": call_id if call_id is not None else _cid("c1"), "call_id": call_id if call_id is not None else "c1",
"parent_call_id": None, "parent_call_id": None,
"session_id": "sess-1", "session_id": "sess-1",
"model": "m", "model": "m",
@@ -125,6 +125,8 @@ async def _record_minimal(
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}' # 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "", "tenant_id": "",
"meta": "{}", "meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown",
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -173,8 +175,9 @@ async def _execute_script(dsn: str, sql: str) -> None:
await conn.close() await conn.close()
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_LEGACY_DDL = """ _LEGACY_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY, call_id TEXT PRIMARY KEY,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -199,56 +202,43 @@ CREATE TABLE {schema}.llm_calls (
@pytest.fixture @pytest.fixture
async def legacy_schema(dsn): async def legacy_schema(pg_sandbox) -> tuple[str, str]:
"""**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。 """一次性沙箱 schema 里造一张 18 列旧表,验证补列(issue #3)。
绝不碰共享的 public.llm_calls: 用 search_path 把 recorder 指向临时 schema, 共享表 `llm_calls` 一个字节都不碰: recorder 由 search_path 指向沙箱 schema,
teardown 只 DROP 自己建的 schema 清理由工厂统一兜底
""" """
import asyncpg sandbox = await pg_sandbox(ddl=_LEGACY_DDL)
return sandbox.dsn, sandbox.schema
name = f"pgwtest_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_LEGACY_DDL.format(schema=name))
finally:
await conn.close()
sep = "&" if "?" in dsn else "?"
yield f"{dsn}{sep}options=-csearch_path%3D{name}", name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
class TestObservabilityColumns: class TestObservabilityColumns:
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。""" """issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
async def test_values_round_trip(self, dsn): async def test_values_round_trip(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) await _record_minimal(recorder, call_id="hit", cached_prompt_tokens=64)
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0) await _record_minimal(recorder, call_id="zero", cached_prompt_tokens=0)
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01") await _record_minimal(recorder, call_id="model", model_reported="MiniMax-01")
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("samp"), sampling='{"seed": 42, "temperature": 0}' recorder, call_id="samp", sampling='{"seed": 42, "temperature": 0}'
) )
rows = await _fetch( rows = await _fetch(
dsn, sandbox.dsn,
"SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls " "SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls "
"WHERE call_id LIKE $1", "WHERE call_id = ANY($1::text[])",
f"{_RUN_PREFIX}-%", ["hit", "zero", "model", "samp"],
) )
by_id = {r["call_id"]: r for r in rows} by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64 assert by_id["hit"]["cached_prompt_tokens"] == 64
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL assert by_id["zero"]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
assert by_id[_cid("model")]["cached_prompt_tokens"] is None assert by_id["model"]["cached_prompt_tokens"] is None
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01" assert by_id["model"]["model_reported"] == "MiniMax-01"
# issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在) # issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在)
assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0} assert json.loads(by_id["samp"]["sampling"]) == {"seed": 42, "temperature": 0}
assert by_id[_cid("hit")]["sampling"] is None assert by_id["hit"]["sampling"] is None
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -258,7 +248,7 @@ class TestObservabilityColumns:
recorder = _recorder(schema_dsn, auto_migrate=True) recorder = _recorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real" recorder, call_id="legacy", cached_prompt_tokens=7, model_reported="m-real"
) )
cols = await _fetch( cols = await _fetch(
schema_dsn, schema_dsn,
@@ -271,7 +261,7 @@ class TestObservabilityColumns:
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1", "SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1",
_cid("legacy"), "legacy",
) )
assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real") assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
finally: finally:
@@ -279,42 +269,44 @@ class TestObservabilityColumns:
class TestSchema: class TestSchema:
async def test_schema_has_frozen_columns_in_order(self, dsn): async def test_schema_has_frozen_columns_in_order(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder) await _record_minimal(recorder)
rows = await _fetch( rows = await _fetch(
dsn, sandbox.dsn,
# `table_schema = $1` 不可省: 不带它,库里任何一个残留 schema 下的同名表
# 都会把自己的列拼进结果,这条断言于是以"列数不符"的形态被别人的残留误伤
"SELECT column_name FROM information_schema.columns " "SELECT column_name FROM information_schema.columns "
"WHERE table_name='llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
sandbox.schema,
) )
assert [r["column_name"] for r in rows] == _EXPECTED_COLUMNS assert [r["column_name"] for r in rows] == _EXPECTED_COLUMNS
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_call_id_idempotent(self, dsn): async def test_call_id_idempotent(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("dup")) await _record_minimal(recorder, call_id="dup")
await _record_minimal(recorder, call_id=_cid("dup"), response="second") await _record_minimal(recorder, call_id="dup", response="second")
rows = await _fetch( rows = await _fetch(
dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("dup") sandbox.dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "dup"
) )
assert [r["response"] for r in rows] == ["ok"] # ON CONFLICT DO NOTHING assert [r["response"] for r in rows] == ["ok"] # ON CONFLICT DO NOTHING
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_concurrent_writes_all_land(self, dsn): async def test_concurrent_writes_all_land(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await asyncio.gather( await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50)) # 沙箱 schema 里只有这一批行,故全表 COUNT 就是本用例写入的行数——
) # 前缀过滤在这里已无事可做(它当年存在只是为了从共享表里认领自己的行)
rows = await _fetch( rows = await _fetch(sandbox.dsn, "SELECT count(*) AS n FROM llm_calls")
dsn,
"SELECT count(*) AS n FROM llm_calls WHERE call_id LIKE $1",
f"{_RUN_PREFIX}-c%",
)
assert rows[0]["n"] == 50 assert rows[0]["n"] == 50
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -338,7 +330,7 @@ class TestDegradation:
"""服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。""" """服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。"""
recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True) recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
await _record_minimal(recorder) # 不抛 await _record_minimal(recorder) # 不抛
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛 await _record_minimal(recorder, call_id="c2") # 已降级短路,同样不抛
await recorder.aclose() await recorder.aclose()
async def test_refused_connection_cools_down_and_retries_after_cooldown(self): async def test_refused_connection_cools_down_and_retries_after_cooldown(self):
@@ -361,7 +353,7 @@ class TestDegradation:
now=clock, now=clock,
) )
try: try:
await _record_minimal(recorder, call_id=_cid("deg1")) await _record_minimal(recorder, call_id="deg1")
first = recorder.telemetry_status first = recorder.telemetry_status
# 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死, # 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死,
# 整进程从此一行遥测都不落、只有重启能恢复 # 整进程从此一行遥测都不落、只有重启能恢复
@@ -372,14 +364,14 @@ class TestDegradation:
assert "建表探测失败" in (first.reason or "") assert "建表探测失败" in (first.reason or "")
clock.advance(30.0) clock.advance(30.0)
await _record_minimal(recorder, call_id=_cid("deg2")) await _record_minimal(recorder, call_id="deg2")
mid = recorder.telemetry_status mid = recorder.telemetry_status
# 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路) # 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路)
assert mid.retry_after_s == pytest.approx(30.0) assert mid.retry_after_s == pytest.approx(30.0)
assert mid.dropped_rows == 2 assert mid.dropped_rows == 2
clock.advance(30.1) clock.advance(30.1)
await _record_minimal(recorder, call_id=_cid("deg3")) await _record_minimal(recorder, call_id="deg3")
after = recorder.telemetry_status after = recorder.telemetry_status
# 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈) # 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈)
assert after.retry_after_s == pytest.approx(60.0) assert after.retry_after_s == pytest.approx(60.0)
@@ -388,23 +380,25 @@ class TestDegradation:
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_row_failure_does_not_poison_later_rows(self, dsn): async def test_row_failure_does_not_poison_later_rows(self, pg_sandbox):
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。""" """运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte") await _record_minimal(recorder, call_id="bad", response="nul\x00byte")
await _record_minimal(recorder, call_id=_cid("good")) await _record_minimal(recorder, call_id="good")
rows = await _fetch( rows = await _fetch(
dsn, sandbox.dsn,
"SELECT call_id FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id", "SELECT call_id FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("bad"), _cid("good")], ["bad", "good"],
) )
assert [r["call_id"] for r in rows] == [_cid("good")] assert [r["call_id"] for r in rows] == ["good"]
finally: finally:
await recorder.aclose() await recorder.aclose()
async def test_aclose_idempotent(self, dsn): async def test_aclose_idempotent(self, pg_sandbox):
recorder = _recorder(dsn, auto_migrate=True) sandbox = await pg_sandbox()
recorder = _recorder(sandbox.dsn, auto_migrate=True)
await _record_minimal(recorder) await _record_minimal(recorder)
await recorder.aclose() await recorder.aclose()
await recorder.aclose() await recorder.aclose()
@@ -424,7 +418,7 @@ def _tagged(dsn: str, app_name: str) -> str:
async def _pool_backend_count(dsn: str, app_name: str) -> int: async def _pool_backend_count(dsn: str, app_name: str) -> int:
"""数**本池**在服务端的连接数(只读查询,不改实例任何状态)。 """数**本池**在服务端的连接数(只读查询,不改实例任何状态)。
只按 run 级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或 只按用例级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例 用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例
(CLAUDE.md §4.6)。本查询自己那条连接走未打 tag 的 DSN,故不会数到自己。 (CLAUDE.md §4.6)。本查询自己那条连接走未打 tag 的 DSN,故不会数到自己。
""" """
@@ -455,73 +449,53 @@ class TestPoolFootprint:
那么多连接"——两件事,只有真实 PG 能证后者。 那么多连接"——两件事,只有真实 PG 能证后者。
""" """
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, dsn): async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, pg_sandbox):
app_name = f"{_RUN_PREFIX}-pool" # run 级唯一,与并跑的其他运行互不可见 sandbox = await pg_sandbox()
recorder = _recorder(_tagged(dsn, app_name), auto_migrate=True) # `application_name` 的唯一性必须**就地**造,不能跟着行隔离前缀一起删掉:
# 连接是实例级资源,schema 隔离对 `pg_stat_activity` 完全无效,换成固定名字
# 会把并跑进程的连接数进来,等于把偶发红从表层搬到连接层(设计 §6.1)。
app_name = f"pgwtest-pool-{uuid4().hex[:12]}"
recorder = _recorder(_tagged(sandbox.dsn, app_name), auto_migrate=True)
try: try:
# 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前 # 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前
# `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10 # `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10
assert await _pool_backend_count(dsn, app_name) == 0 assert await _pool_backend_count(sandbox.dsn, app_name) == 0
await _record_minimal(recorder, call_id=_cid("fp1")) await _record_minimal(recorder, call_id="fp1")
# **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功), # **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功),
# 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。 # 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。
# 故这是个确定值,不是"某一刻恰好的采样" # 故这是个确定值,不是"某一刻恰好的采样"
assert await _pool_backend_count(dsn, app_name) == 1 assert await _pool_backend_count(sandbox.dsn, app_name) == 1
await asyncio.gather( await asyncio.gather(
*(_record_minimal(recorder, call_id=_cid(f"fp{i}")) for i in range(2, 22)) *(_record_minimal(recorder, call_id=f"fp{i}") for i in range(2, 22))
) )
steady = await _pool_backend_count(dsn, app_name) steady = await _pool_backend_count(sandbox.dsn, app_name)
# 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池, # 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池,
# 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过 # 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过
assert 1 <= steady <= _POOL_MAX assert 1 <= steady <= _POOL_MAX
finally: finally:
await recorder.aclose() await recorder.aclose()
assert await _settled_backend_count(dsn, app_name) == 0 # 关闭即归还全部连接 # 关闭即归还全部连接
assert await _settled_backend_count(sandbox.dsn, app_name) == 0
_PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据 _PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据
@pytest.fixture @pytest.fixture
async def least_privilege_dsn(dsn): async def least_privilege_dsn(pg_sandbox) -> tuple[str, str]:
"""临时 schema + 临时角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。 """一次性 schema + 独占角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
这是 issue #9 的现场——最小权限部署的标准形态。fixture 建的一切 这是 issue #9 的现场——最小权限部署的标准形态。`role="grantee"` 的语义恰是它:
(schema、表、角色)都在 teardown 里删净,共享的 public.llm_calls 不受影响; 表由 admin 建好(属主不是应用账号),角色只拿到 `USAGE` 加表级 grants,
连不上或无权建角色(非超级用户)时 skip,不让 CI 假绿 唯独没有 `CREATE ON SCHEMA`——缺的正是这一项
无权建角色(非超级用户)时工厂自己 skip,不让 CI 假绿。
""" """
import asyncpg
from polygateway.telemetry.schema import PG_DDL from polygateway.telemetry.schema import PG_DDL
name = f"pgwtest_lp_{uuid4().hex[:8]}" sandbox = await pg_sandbox(ddl=PG_DDL, role="grantee")
admin = await asyncpg.connect(dsn, timeout=10) return sandbox.dsn, sandbox.schema
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(f"SET search_path = {name}")
await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
sep = "&" if "?" in low else "?"
yield f"{low}{sep}options=-csearch_path%3D{name}", name
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
class TestLeastPrivilegeDeployment: class TestLeastPrivilegeDeployment:
@@ -549,26 +523,28 @@ class TestLeastPrivilegeDeployment:
low_dsn, schema = least_privilege_dsn low_dsn, schema = least_privilege_dsn
recorder = _recorder(low_dsn, auto_migrate=True) recorder = _recorder(low_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lp1")) await _record_minimal(recorder, call_id="lp1")
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5) await _record_minimal(recorder, call_id="lp2", cost=1.5)
assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级 assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级
rows = await _fetch( rows = await _fetch(
low_dsn, low_dsn,
"SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id", "SELECT call_id, cost FROM llm_calls "
f"{_RUN_PREFIX}-lp%", "WHERE call_id = ANY($1::text[]) ORDER BY call_id",
["lp1", "lp2"],
) )
assert [(r["call_id"], r["cost"]) for r in rows] == [ assert [(r["call_id"], r["cost"]) for r in rows] == [
(_cid("lp1"), None), ("lp1", None),
(_cid("lp2"), 1.5), ("lp2", 1.5),
] ]
assert schema # teardown 会连表带角色删净 assert schema # teardown 会连表带角色删净
finally: finally:
await recorder.aclose() await recorder.aclose()
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度 # issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_PRE_TENANT_DDL = """ _PRE_TENANT_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY, call_id TEXT PRIMARY KEY,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -595,17 +571,21 @@ CREATE TABLE {schema}.llm_calls (
) )
""" """
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故这行历史数据的 call_id 直接内联成
# 字面量('old' 是本文件固定的测试常量,不是外部输入)。
_PRE_TENANT_INSERT = ( _PRE_TENANT_INSERT = (
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, " "INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) " "prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)" "VALUES ('old', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
) )
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度 # `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉此后新增的三列
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。 # 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。 # 去掉后的顺序与 DDL 逐字一致(这三列在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")] _PRE_TENANT_COLUMNS = [
c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta", "thinking_observation")
]
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个 # 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"] _PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
@@ -632,82 +612,36 @@ async def captured_warnings():
@pytest.fixture @pytest.fixture
async def pre_tenant_schema(dsn): async def pre_tenant_schema(pg_sandbox) -> tuple[str, str]:
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。 """一次性沙箱里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列, 共享表 `llm_calls` 一个字节都不碰——本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。 指望它还是旧形态的测试第二次跑就会空转。
""" """
import asyncpg sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, extra=(_PRE_TENANT_INSERT,))
return sandbox.dsn, sandbox.schema
name = f"pgwtest_pre_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture @pytest.fixture
async def fresh_schema(dsn): async def fresh_schema(pg_sandbox) -> tuple[str, str]:
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。""" """空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
import asyncpg sandbox = await pg_sandbox()
return sandbox.dsn, sandbox.schema
name = f"pgwtest_new_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture @pytest.fixture
async def least_privilege_pre_tenant_dsn(dsn): async def least_privilege_pre_tenant_dsn(pg_sandbox) -> str:
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。 """22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表 与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去 (测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。 并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
"""
import asyncpg
name = f"pgwtest_lppre_{uuid4().hex[:8]}" `role="grantee"` 正是这个现场: 表由 admin 建好(属主不是应用账号),角色只拿到
admin = await asyncpg.connect(dsn, timeout=10) 表级 SELECT/INSERT。
try: """
if not await admin.fetchval( sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, role="grantee")
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user" return sandbox.dsn
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
yield _search_path_dsn(low, name)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
class TestCallerDimensionsAcceptance: class TestCallerDimensionsAcceptance:
@@ -719,7 +653,7 @@ class TestCallerDimensionsAcceptance:
recorder = _recorder(fresh_dsn, auto_migrate=True) recorder = _recorder(fresh_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}' recorder, call_id="dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
) )
cols = await _fetch( cols = await _fetch(
fresh_dsn, fresh_dsn,
@@ -731,7 +665,7 @@ class TestCallerDimensionsAcceptance:
rows = await _fetch( rows = await _fetch(
fresh_dsn, fresh_dsn,
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1", "SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
_cid("dim"), "dim",
) )
assert rows[0]["tenant_id"] == "tenant-a" assert rows[0]["tenant_id"] == "tenant-a"
assert json.loads(rows[0]["meta"]) == {"batch": "b7"} assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
@@ -752,28 +686,26 @@ class TestCallerDimensionsAcceptance:
schema_dsn, schema = pre_tenant_schema schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=True) recorder = _recorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(recorder, call_id="new", tenant_id="tenant-a", meta='{"k": 1}')
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch( cols = await _fetch(
schema_dsn, schema_dsn,
"SELECT column_name FROM information_schema.columns " "SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾 # 22 → 25 个 recorder 字段(加 created_at 共 26 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
"SELECT call_id, tenant_id, meta FROM llm_calls " "SELECT call_id, tenant_id, meta FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id", "WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("new"), _cid("old")], ["new", "old"],
) )
by_id = {r["call_id"]: r for r in rows} by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("new")]["tenant_id"] == "tenant-a" assert by_id["new"]["tenant_id"] == "tenant-a"
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1} assert json.loads(by_id["new"]["meta"]) == {"k": 1}
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert by_id["old"]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id[_cid("old")]["meta"]) == {} assert json.loads(by_id["old"]["meta"]) == {}
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -804,7 +736,7 @@ class TestCallerDimensionsAcceptance:
""" """
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True) recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛 await _record_minimal(recorder, call_id="lpp1") # 不得抛
assert recorder.telemetry_status.degraded is False assert recorder.telemetry_status.degraded is False
assert any("补列失败" in m for m in captured_warnings) assert any("补列失败" in m for m in captured_warnings)
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据 # 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
@@ -816,8 +748,9 @@ class TestCallerDimensionsAcceptance:
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。 # issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) —— # PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。 # 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
_PARTITIONED_DDL = """ _PARTITIONED_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE llm_calls (
call_id TEXT NOT NULL, call_id TEXT NOT NULL,
parent_call_id TEXT, parent_call_id TEXT,
session_id TEXT, session_id TEXT,
@@ -842,14 +775,14 @@ CREATE TABLE {schema}.llm_calls (
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '', tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb, meta JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (call_id, created_at) PRIMARY KEY (call_id, created_at)
) PARTITION BY RANGE (created_at) ) PARTITION BY RANGE (created_at)
""" """
# 仍带 `.format`,但只为月份边界——表名两处都已是裸名,由 search_path 定位
_PARTITION_DDL = ( _PARTITION_DDL = (
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls " "CREATE TABLE llm_calls_current PARTITION OF llm_calls FOR VALUES FROM ('{start}') TO ('{end}')"
"FOR VALUES FROM ('{start}') TO ('{end}')"
) )
@@ -863,29 +796,18 @@ def _current_month_bounds() -> tuple[str, str]:
@pytest.fixture @pytest.fixture
async def partitioned_schema(dsn): async def partitioned_schema(pg_sandbox) -> tuple[str, str]:
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。 """一次性沙箱里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
与 legacy_schema 同款隔离: 绝不碰共享的 public.llm_calls,teardown 只 DROP `legacy_schema` 同款隔离: 共享表 `llm_calls` 一个字节都不碰,工厂的
自己建的 schema(CASCADE 连分区一并删) `DROP SCHEMA ... CASCADE` 连分区子表一并删。
""" """
import asyncpg
name = f"pgwtest_part_{uuid4().hex[:8]}"
start, end = _current_month_bounds() start, end = _current_month_bounds()
conn = await asyncpg.connect(dsn, timeout=10) sandbox = await pg_sandbox(
try: ddl=_PARTITIONED_DDL,
await conn.execute(f"CREATE SCHEMA {name}") extra=(_PARTITION_DDL.format(start=start, end=end),),
await conn.execute(_PARTITIONED_DDL.format(schema=name)) )
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end)) return sandbox.dsn, sandbox.schema
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
class TestConflictTargetFreeInsert: class TestConflictTargetFreeInsert:
@@ -900,11 +822,11 @@ class TestConflictTargetFreeInsert:
fresh_dsn, _ = fresh_schema fresh_dsn, _ = fresh_schema
recorder = _recorder(fresh_dsn, auto_migrate=True) recorder = _recorder(fresh_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("nodup")) await _record_minimal(recorder, call_id="nodup")
await _record_minimal(recorder, call_id=_cid("nodup"), response="second") await _record_minimal(recorder, call_id="nodup", response="second")
assert [m for m in captured_warnings if "写入失败" in m] == [] assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch( rows = await _fetch(
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup") fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "nodup"
) )
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等 assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
finally: finally:
@@ -921,14 +843,14 @@ class TestConflictTargetFreeInsert:
part_dsn, _ = partitioned_schema part_dsn, _ = partitioned_schema
recorder = _recorder(part_dsn, auto_migrate=True) recorder = _recorder(part_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p") await _record_minimal(recorder, call_id="part", tenant_id="tenant-p")
assert [m for m in captured_warnings if "写入失败" in m] == [] assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch( rows = await _fetch(
part_dsn, part_dsn,
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1", "SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
_cid("part"), "part",
) )
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")] assert [(r["call_id"], r["tenant_id"]) for r in rows] == [("part", "tenant-p")]
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -944,16 +866,16 @@ class TestManualSchemaModeAcceptance:
async def test_manual_leaves_the_stale_table_untouched( async def test_manual_leaves_the_stale_table_untouched(
self, pre_tenant_schema, captured_warnings self, pre_tenant_schema, captured_warnings
): ):
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的维度静默不写。 """22 字段旧表 + manual: 列一个不加,行照常落库,缺的维度静默不写。
与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照: 与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 26 之别。
""" """
schema_dsn, schema = pre_tenant_schema schema_dsn, schema = pre_tenant_schema
recorder = _recorder(schema_dsn, auto_migrate=False) recorder = _recorder(schema_dsn, auto_migrate=False)
try: try:
recorded = await _record_minimal( recorded = await _record_minimal(
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}' recorder, call_id="man", tenant_id="tenant-a", meta='{"k": 1}'
) )
cols = await _fetch( cols = await _fetch(
schema_dsn, schema_dsn,
@@ -966,7 +888,7 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS) names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch( rows = await _fetch(
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man") schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", "man"
) )
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收 assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位 # 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
@@ -976,7 +898,8 @@ class TestManualSchemaModeAcceptance:
assert [m for m in captured_warnings if "补列失败" in m] == [] assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏 assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
assert "以下维度不会被记录: tenant_id, meta" in notices[0] # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿
assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0]
finally: finally:
await recorder.aclose() await recorder.aclose()
@@ -993,16 +916,17 @@ class TestManualSchemaModeAcceptance:
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False) recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
try: try:
recorded = await _record_minimal( recorded = await _record_minimal(
recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}' recorder, call_id="manlp1", tenant_id="tenant-b", meta='{"k": 2}'
) )
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5) await _record_minimal(recorder, call_id="manlp2", cost=2.5)
assert [m for m in captured_warnings if "补列失败" in m] == [] assert [m for m in captured_warnings if "补列失败" in m] == []
assert [m for m in captured_warnings if "写入失败" in m] == [] assert [m for m in captured_warnings if "写入失败" in m] == []
assert recorder.telemetry_status.degraded is False assert recorder.telemetry_status.degraded is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m] notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复 assert len(notices) == 1 # 准备期一次,第二行不再重复
assert "以下维度不会被记录: tenant_id, meta" in notices[0] # 逐字钉住三个维度: 前缀断言会让将来漏进告警的新列照样绿
assert "以下维度不会被记录: tenant_id, meta, thinking_observation。" in notices[0]
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名 # 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert ( assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0] "ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
@@ -1024,10 +948,10 @@ class TestManualSchemaModeAcceptance:
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS) names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch( rows = await _fetch(
least_privilege_pre_tenant_dsn, least_privilege_pre_tenant_dsn,
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id", f"SELECT {names} FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
f"{_RUN_PREFIX}-manlp%", ["manlp1", "manlp2"],
) )
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")] assert [r["call_id"] for r in rows] == ["manlp1", "manlp2"]
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS} assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
assert rows[1]["cost"] == 2.5 assert rows[1]["cost"] == 2.5
finally: finally:
@@ -1060,7 +984,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 # 物理列 = 25 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"} assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS assert actual == _EXPECTED_COLUMNS
@@ -1177,14 +1101,20 @@ async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -
@pytest.fixture @pytest.fixture
async def production_template(dsn): async def production_template(template_admin_dsn):
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。 """在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls` **有意不收敛到 `pg_sandbox`**(设计 §7.1 末段): 它要建三个角色、跑 README
解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的,工厂
强行接管会把这些语义压扁。故它是本文件唯一仍持管理连接的 fixture。
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享表 `llm_calls`
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。 一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
""" """
import asyncpg import asyncpg
dsn = template_admin_dsn
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]
schema = f"pgwtpl_{suffix}" schema = f"pgwtpl_{suffix}"
roles = { roles = {
@@ -1199,7 +1129,7 @@ async def production_template(dsn):
f"README 的模板锚点与预期不符: {list(blocks)}" f"README 的模板锚点与预期不符: {list(blocks)}"
) )
seeded = (_cid("tpl-a"), _cid("tpl-b")) seeded = ("tpl-a", "tpl-b")
admin_dsn = _search_path_dsn(dsn, schema) admin_dsn = _search_path_dsn(dsn, schema)
admin = await asyncpg.connect(dsn, timeout=10) admin = await asyncpg.connect(dsn, timeout=10)
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException, # 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
@@ -1221,9 +1151,9 @@ async def production_template(dsn):
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True): for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
await admin.execute(_TEMPLATE_INSERT, call_id, tenant) await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
except BaseException: except BaseException:
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls, # 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,而三个
# `TestSchema` 那条 table_name 查 information_schema 的用例不带 # 角色是**全局**对象,不随库消失。`TestSchema` 那条用例如今自带 table_schema
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤 # 过滤已不再受残留影响,但残留本身仍是这个共享实例上的垃圾,该清还是要清。
await admin.close() await admin.close()
await _drop_template_objects(dsn, schema, roles) await _drop_template_objects(dsn, schema, roles)
raise raise
@@ -1271,6 +1201,19 @@ class TestProductionTemplate:
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色 # 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}" assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
# 再钉死列的**同源性**: `table` 块必须靠 `LIKE llm_calls_seed` 从库自建的表派生
# 列,绝不能手抄一份列定义。手抄的那份会与 telemetry/schema.py 各自漂移,而漂移
# 的表现是照模板部署的下游少掉新增列——manual 档下库按现有列裁剪写入,那一列
# 就此静默消失,正是可观测性 issue 要消灭的那类静默。
table_sql = blocks["table"]
assert "LIKE llm_calls_seed" in table_sql, "生产模板的列必须由 LIKE 派生,不得手抄"
inlined = [
column
for column in COLUMNS
if re.search(rf"^\s*{column}\s+[A-Z]", table_sql, re.MULTILINE)
]
assert not inlined, f"生产模板内联了列定义 {inlined},与 telemetry/schema.py 必然漂移"
async def test_app_can_insert_but_cannot_mutate(self, production_template): async def test_app_can_insert_but_cannot_mutate(self, production_template):
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。 """应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
@@ -1283,17 +1226,17 @@ class TestProductionTemplate:
env = production_template env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10) conn = await asyncpg.connect(env.app_dsn, timeout=10)
try: try:
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a") await conn.execute(_TEMPLATE_INSERT, "tpl-app", "tenant-a")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError): with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app")) await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", "tpl-app")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError): with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("UPDATE llm_calls SET response = 'x'") await conn.execute("UPDATE llm_calls SET response = 'x'")
finally: finally:
await conn.close() await conn.close()
rows = await _fetch( rows = await _fetch(
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app") env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", "tpl-app"
) )
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了 assert [r["call_id"] for r in rows] == ["tpl-app"] # 写入真落库了
async def test_report_can_read_but_cannot_write(self, production_template): async def test_report_can_read_but_cannot_write(self, production_template):
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。""" """报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
@@ -1303,7 +1246,7 @@ class TestProductionTemplate:
conn = await asyncpg.connect(env.report_dsn, timeout=10) conn = await asyncpg.connect(env.report_dsn, timeout=10)
try: try:
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError): with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a") await conn.execute(_TEMPLATE_INSERT, "tpl-rpt", "tenant-a")
async with conn.transaction(): async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)") await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls") rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
+209 -106
View File
@@ -1,11 +1,12 @@
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。 """`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。 隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
与真实批跑共用。故**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到。
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是 这条纪律取代了此前那条"跑完对比共享表行数"的安全网——行数快照守的是安全属性,却把它
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path), 编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴。
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls` 权限边界两个方向都没有。
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,10 +17,8 @@ import subprocess
import sys import sys
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from dotenv import dotenv_values
from polygateway.telemetry.schema import PG_DDL from polygateway.telemetry.schema import PG_DDL
@@ -55,20 +54,6 @@ def _partitioned_ddl() -> str:
) )
def _dsn_value() -> str | None:
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _stamp(delta: timedelta) -> datetime: def _stamp(delta: timedelta) -> datetime:
return datetime.now(UTC) + delta return datetime.now(UTC) + delta
@@ -84,43 +69,6 @@ def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedP
) )
@pytest.fixture
async def dsn():
value = _dsn_value()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
import asyncpg
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(f"SET search_path = {name}")
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
return name
async def _drop_schema(dsn_value: str, name: str) -> None:
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None: async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
import asyncpg import asyncpg
@@ -142,50 +90,31 @@ async def _call_ids(schema_dsn: str) -> list[str]:
return [r["call_id"] for r in rows] return [r["call_id"] for r in rows]
async def _public_count(dsn_value: str) -> int:
"""共享表的行数;本测试全程不得让它变动一行。"""
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
return -1
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
finally:
await conn.close()
@pytest.fixture @pytest.fixture
async def partitioned_schema(dsn): async def partitioned_sandbox(pg_sandbox):
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。""" """临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
name = await _make_schema( return await pg_sandbox(
dsn, ddl=_partitioned_ddl(),
"part",
_partitioned_ddl(),
extra=( extra=(
"CREATE TABLE llm_calls_all PARTITION OF llm_calls " "CREATE TABLE llm_calls_all PARTITION OF llm_calls "
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')", "FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
), ),
role="owner",
) )
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
@pytest.fixture @pytest.fixture
async def plain_schema(dsn): async def plain_sandbox(pg_sandbox):
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。""" """临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
name = await _make_schema(dsn, "plain", PG_DDL) return await pg_sandbox(ddl=PG_DDL, role="owner")
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
class TestPartitionedTarget: class TestPartitionedTarget:
async def test_partitioned_table_exits_three_without_deleting_anything( async def test_partitioned_table_exits_three_without_deleting_anything(
self, partitioned_schema self, partitioned_sandbox
): ):
schema_dsn, schema = partitioned_schema
await _seed( await _seed(
schema_dsn, partitioned_sandbox.dsn,
[ [
("part-old-1", "", _stamp(timedelta(days=-30))), ("part-old-1", "", _stamp(timedelta(days=-30))),
("part-old-2", "acme", _stamp(timedelta(days=-20))), ("part-old-2", "acme", _stamp(timedelta(days=-20))),
@@ -194,24 +123,28 @@ class TestPartitionedTarget:
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住 # 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
result = _run( result = _run(
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply" "--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
) )
assert result.returncode == 3, (result.stdout, result.stderr) assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined assert "DROP PARTITION" in combined
assert "DETACH" in combined assert "DETACH" in combined
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"] assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据 # 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
assert f"{schema}.llm_calls" in result.stdout assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout
class TestPlainTableBatches: class TestPlainTableBatches:
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn): async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
schema_dsn, schema = plain_schema
before_public = await _public_count(dsn)
await _seed( await _seed(
schema_dsn, plain_sandbox.dsn,
[ [
("old-1", "", _stamp(timedelta(days=-40))), ("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))), ("old-2", "acme", _stamp(timedelta(days=-30))),
@@ -227,7 +160,7 @@ class TestPlainTableBatches:
"--backend", "--backend",
"postgres", "postgres",
"--dsn", "--dsn",
schema_dsn, plain_sandbox.dsn,
"--older-than-days", "--older-than-days",
"7", "7",
"--apply", "--apply",
@@ -236,8 +169,8 @@ class TestPlainTableBatches:
) )
assert result.returncode == 0, (result.stdout, result.stderr) assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"] assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{schema}.llm_calls" in result.stdout assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout assert "'acme': 3" in result.stdout
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条 # 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
@@ -245,30 +178,200 @@ class TestPlainTableBatches:
assert "批次 3" in result.stdout assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout assert "已删除 5 行" in result.stdout
assert await _public_count(dsn) == before_public
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema): async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
schema_dsn, _ = plain_schema await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7") result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr) assert result.returncode == 0, (result.stdout, result.stderr)
assert "将删除行数: 1" in result.stdout assert "将删除行数: 1" in result.stdout
assert "dry-run" in result.stdout assert "dry-run" in result.stdout
assert await _call_ids(schema_dsn) == ["old-1"] assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestExplicitTable:
"""`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。
单测只能验参数分类,验不了 `to_regclass` 的语义——schema 不存在返 NULL 而非抛错、
引号限定名区分大小写、无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立。
"""
async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
await _seed(
plain_sandbox.dsn,
[
("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))),
("old-3", "acme", _stamp(timedelta(days=-20))),
("old-4", "acme", _stamp(timedelta(days=-15))),
("old-5", "", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--batch-size",
"2",
"--table",
f"{plain_sandbox.schema}.llm_calls",
)
# 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout
assert "批次 1" in result.stdout
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
"""schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用""""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
missing = "pgw_s_nosuchxxxxxxxx"
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{missing}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert missing in result.stderr
assert "llm_calls" in result.stderr
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
"""拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
sandbox_a.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{sandbox_b.schema}.llm_calls",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert await _call_ids(sandbox_a.dsn) == ["a-old"]
assert await _call_ids(sandbox_b.dsn) == ["b-old"]
async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
partitioned_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
"--table",
f"{partitioned_sandbox.schema}.llm_calls",
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]
class TestInferredTargetHint:
async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
"""未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。
该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层。
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 0, (result.stdout, result.stderr)
assert "search_path" in result.stdout
assert "--table" in result.stdout
async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
"""dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "--table" not in result.stdout
class TestSearchPathFallsThrough:
async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
"""最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。
用沙箱角色的**裸** DSN 跑(search_path 回落 `"$user", public`,而角色名与 schema
名有意错开,故 `"$user"` 命不中沙箱),不给 `--table`,带 `--apply`。角色对共享表
无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
没有则解析不到。**不断言 PG 的英文原文**——服务端 `lc_messages` 不由测试掌握。
"""
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run(
"--backend",
"postgres",
"--dsn",
plain_sandbox.bare_dsn,
"--older-than-days",
"7",
"--apply",
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert result.stderr.strip()
assert "llm_calls" in result.stderr
# 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
class TestMissingAsyncpg: class TestMissingAsyncpg:
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path): async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。 """缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该 用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema, 场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。 这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
""" """
schema_dsn, _ = plain_schema await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
stub = tmp_path / "stub" stub = tmp_path / "stub"
stub.mkdir() stub.mkdir()
(stub / "asyncpg.py").write_text( (stub / "asyncpg.py").write_text(
@@ -285,7 +388,7 @@ class TestMissingAsyncpg:
"--backend", "--backend",
"postgres", "postgres",
"--dsn", "--dsn",
schema_dsn, plain_sandbox.dsn,
"--older-than-days", "--older-than-days",
"7", "7",
"--apply", "--apply",
@@ -295,4 +398,4 @@ class TestMissingAsyncpg:
assert result.returncode == 2, (result.stdout, result.stderr) assert result.returncode == 2, (result.stdout, result.stderr)
assert "asyncpg" in result.stderr assert "asyncpg" in result.stderr
assert "pip install" in result.stderr assert "pip install" in result.stderr
assert await _call_ids(schema_dsn) == ["old-1"] assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
+76 -1
View File
@@ -5,12 +5,13 @@ import hashlib
import json import json
import pytest import pytest
from loguru import logger
from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.cache import InMemoryCache
from polygateway.errors import ResultInvalidError, TransientError from polygateway.errors import ResultInvalidError, TransientError
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.types import ChatRequest, LLMResponse, SourceConfig from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
_MSGS = [{"role": "user", "content": "hi"}] _MSGS = [{"role": "user", "content": "hi"}]
@@ -249,6 +250,80 @@ class TestObservabilityFieldsOnHit:
assert hit.cached_prompt_tokens is None and hit.model_reported is None 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 _BrokenBackend: class _BrokenBackend:
async def get(self, key): async def get(self, key):
raise ConnectionError("redis down") raise ConnectionError("redis down")
+157 -1
View File
@@ -22,7 +22,7 @@ from polygateway.transports.openai_compat import (
_iter_sse_deltas, _iter_sse_deltas,
_sse_data_payload, _sse_data_payload,
) )
from polygateway.types import ChatRequest, LLMResponse, SourceConfig from polygateway.types import ChatRequest, LLMResponse, SourceConfig, ThinkingObservation
def _source(**overrides): def _source(**overrides):
@@ -471,6 +471,162 @@ class TestReasoningTokens:
assert result.reasoning_tokens is None 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)}"
class TestNonStreamFastPath: class TestNonStreamFastPath:
async def test_non_stream_parses_message(self): async def test_non_stream_parses_message(self):
def handler(request): def handler(request):
+25
View File
@@ -49,3 +49,28 @@ def test_telemetry_status_exported():
assert "TelemetryStatus" in polygateway.__all__ assert "TelemetryStatus" in polygateway.__all__
assert polygateway.TelemetryStatus is not None assert polygateway.TelemetryStatus is not None
assert "TelemetryStatusProvider" not in polygateway.__all__ 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` 即可,导出即多一份
永久承诺
"""
for name in (
"ThinkingCapability",
"ThinkingObservation",
"ThinkingUnsupportedError",
"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__
+1 -1
View File
@@ -244,7 +244,7 @@ class TestTelemetryRecorderSignature:
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params) assert {"tenant_id", "meta"} <= set(params)
@pytest.mark.parametrize("name", ["tenant_id", "meta"]) @pytest.mark.parametrize("name", ["tenant_id", "meta", "thinking_observation"])
def test_caller_dimensions_have_no_default(self, name): def test_caller_dimensions_have_no_default(self, name):
import inspect import inspect
-86
View File
@@ -1,18 +1,12 @@
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。""" """providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
import pytest import pytest
from loguru import logger
from polygateway.providers import ( from polygateway.providers import (
DEFAULT_CAPABILITIES,
DEFAULT_PROFILES, DEFAULT_PROFILES,
ProviderProfile, ProviderProfile,
ThinkingCapability,
get_capability,
get_provider, get_provider,
register_capability,
register_provider, register_provider,
resolve_thinking,
) )
@@ -75,83 +69,3 @@ class TestPureFunctionRegistration:
def test_default_profiles_mapping_is_read_only(self): def test_default_profiles_mapping_is_read_only(self):
with pytest.raises(TypeError): with pytest.raises(TypeError):
DEFAULT_PROFILES["hack"] = None # type: ignore[index] DEFAULT_PROFILES["hack"] = None # type: ignore[index]
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestThinkingCapability:
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
def test_registered_models_carry_evidence(self):
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
cap = get_capability(model)
assert cap is not None and cap.evidence.strip()
def test_m3_can_disable_but_m2x_cannot(self):
assert get_capability("MiniMax-M3").can_disable is True
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
def test_unregistered_model_is_unknown(self):
assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability(True, "实测"))
assert get_capability("x-1", table=table) is not None
assert get_capability("x-1") is None # 默认表未被污染
def test_default_capabilities_mapping_is_read_only(self):
with pytest.raises(TypeError):
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
class TestResolveThinking:
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
def test_rule1_none_injects_nothing(self):
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
assert got == {}
@pytest.mark.parametrize("enable", [True, False])
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
with pytest.raises(ValueError, match="register_provider") as exc:
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
assert "extra_body" in str(exc.value)
def test_rule3_unregistered_model_warns_but_passes(self):
messages, sink_id = _warnings()
try:
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
finally:
logger.remove(sink_id)
assert got == {"reasoning_effort": "none"}
assert any("MiniMax-M9" in m for m in messages)
def test_rule4_cannot_disable_raises_with_the_model_name(self):
cap = get_capability("MiniMax-M2.7")
with pytest.raises(ValueError, match="MiniMax-M2.7"):
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
def test_rule4_only_blocks_the_off_direction(self):
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
cap = get_capability("MiniMax-M2.7")
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
assert got == {"reasoning_effort": "medium"}
def test_rule5_normal_path(self):
cap = get_capability("MiniMax-M3")
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
"reasoning_effort": "none"
}
def test_unknown_shape_beats_capability_check(self):
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
cap = ThinkingCapability(can_disable=False, evidence="构造")
with pytest.raises(ValueError, match="register_provider"):
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
+160
View File
@@ -261,6 +261,157 @@ class TestUsageErrors:
assert result.returncode == 1 assert result.returncode == 1
# --- --table 的参数分类(issue #18 设计 §4.2);真实解析行为在集成层验 ---
def test_sqlite_with_table_exits_one(self, tmp_path):
"""SQLite 库文件即目标,无 schema 概念,故 `--table` 在该分支无歧义可消。"""
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--table",
"some_schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_without_schema_qualifier_exits_one(self):
"""单段等于没声明: 目标仍由 `search_path` 决定,隐式性原样保留,故拒绝。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_empty_segment_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
".llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_with_quote_in_a_segment_exits_one(self):
"""含引号的复杂标识符不支持: 此时退回不给 `--table` 的路径(见 epilog)。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
'sch"ema.llm_calls',
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
def test_table_naming_another_table_exits_one(self):
"""表名段锁死: 不加这条,`--table` 会把本脚本扩成"任意同形表删除工具""""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"audit.events",
)
assert result.returncode == 1
assert "--table" in result.stderr
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
assert "unrecognized" not in result.stderr
# 错误消息要当场把边界说清: 本脚本的作用域到 llm_calls 为止
assert "llm_calls" in result.stderr
class TestTableIdentifierWhitelist:
"""schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。
这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在),
**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人
"""
def test_schema_with_a_space_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"bad schema.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_schema_with_a_semicolon_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--table",
"a;b.llm_calls",
)
assert result.returncode == 1
assert "--table" in result.stderr
def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self):
"""收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。"""
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://127.0.0.1:1/nope",
"--older-than-days",
"7",
"--table",
"pgw_s_a1b2c3.llm_calls",
)
assert result.returncode == 2
class TestHelp: class TestHelp:
def test_help_names_the_maintenance_role_and_the_recommended_path(self): def test_help_names_the_maintenance_role_and_the_recommended_path(self):
@@ -271,3 +422,12 @@ class TestHelp:
assert "维护角色" in result.stdout assert "维护角色" in result.stdout
assert "REVOKE" in result.stdout assert "REVOKE" in result.stdout
assert "PARTITION" in result.stdout assert "PARTITION" in result.stdout
def test_help_states_the_table_name_is_fixed(self):
"""`--table` 只有 schema 一段可变,这条边界必须写在运维会读到的地方。"""
result = _run("--help")
assert result.returncode == 0
assert "--table" in result.stdout
assert "只清理" in result.stdout
assert "llm_calls" in result.stdout
+24
View File
@@ -27,6 +27,7 @@ from polygateway.types import (
GlobalLimits, GlobalLimits,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
ThinkingObservation,
TransportResult, TransportResult,
) )
from tests.contracts.conftest import FakeClock from tests.contracts.conftest import FakeClock
@@ -225,6 +226,29 @@ class TestObservabilityPassthrough:
assert resp.cached_prompt_tokens is None and resp.model_reported is None assert resp.cached_prompt_tokens is None and resp.model_reported is None
assert resp.reasoning_tokens 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
class TestRetryAndFailover: class TestRetryAndFailover:
async def test_transient_switches_source_then_succeeds(self): async def test_transient_switches_source_then_succeeds(self):
+152 -16
View File
@@ -9,6 +9,7 @@ import subprocess
from pathlib import Path from pathlib import Path
import pytest import pytest
from loguru import logger
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
@@ -30,6 +31,7 @@ from polygateway.types import (
OcrTextTransportResult, OcrTextTransportResult,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
ThinkingObservation,
) )
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}], session_id="sess-1") _REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}], session_id="sess-1")
@@ -60,6 +62,7 @@ _EXPECTED_COLUMNS = [
"reasoning_tokens", "reasoning_tokens",
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation",
] ]
@@ -127,6 +130,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}' # 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "", "tenant_id": "",
"meta": "{}", "meta": "{}",
# 同样已由 emitter 归一化: 枚举取 .value 后才下沉,recorder 只见裸 str
"thinking_observation": "unknown",
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -172,16 +177,16 @@ _FROZEN_SQLITE_INSERT = (
"INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, " "INSERT OR IGNORE INTO llm_calls (call_id, parent_call_id, session_id, model, provider, "
"source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, " "source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, "
"latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, " "latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, "
"model_reported, sampling, reasoning_tokens, tenant_id, meta) " "model_reported, sampling, reasoning_tokens, tenant_id, meta, thinking_observation) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
) )
_FROZEN_PG_INSERT = ( _FROZEN_PG_INSERT = (
"INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, " "INSERT INTO llm_calls (call_id, parent_call_id, session_id, model, provider, source_name, "
"messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, " "messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, "
"ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, " "ttft_ms, max_inter_token_ms, cache_hit, error, cost, cached_prompt_tokens, model_reported, "
"sampling, reasoning_tokens, tenant_id, meta) " "sampling, reasoning_tokens, tenant_id, meta, thinking_observation) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, " "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, "
"$19, $20, $21, $22, $23, $24) " "$19, $20, $21, $22, $23, $24, $25) "
# 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、 # 无冲突目标(issue #13 Task 2): 带 `(call_id)` 的版本在按 created_at 分区、
# 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入 # 主键为 (call_id, created_at) 的表上匹配不到约束,PG 直接拒收整条写入
"ON CONFLICT DO NOTHING" "ON CONFLICT DO NOTHING"
@@ -207,7 +212,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at # COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"] assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 24 assert len(COLUMNS) == 25
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位) # 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL): for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -221,8 +226,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
) )
assert PG_BACKFILL[-1] == ( assert PG_BACKFILL[-1] == (
"meta", "thinking_observation",
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE llm_calls ADD COLUMN thinking_observation TEXT",
) )
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL) assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -270,7 +275,7 @@ class TestSchemaModule:
pg = telemetry_schema_sql("postgres") pg = telemetry_schema_sql("postgres")
lite = telemetry_schema_sql("sqlite") lite = telemetry_schema_sql("sqlite")
for script in (pg, lite): for script in (pg, lite):
# 24 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致 # 25 个 INSERT 字段 + created_at 全在,且首次出现顺序与建表 DDL 一致
assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(script, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
assert "CREATE TABLE IF NOT EXISTS llm_calls" in script assert "CREATE TABLE IF NOT EXISTS llm_calls" in script
# 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同) # 人执行的那份必须幂等: PG 用 ADD COLUMN IF NOT EXISTS(与库内那份有意不同)
@@ -304,11 +309,11 @@ class TestBackendColumnParity:
assert sqlite.COLUMNS is COLUMNS assert sqlite.COLUMNS is COLUMNS
assert postgres.COLUMNS is COLUMNS assert postgres.COLUMNS is COLUMNS
def test_caller_dimensions_are_appended_last(self): def test_new_columns_are_appended_last(self):
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-2:] == ("tenant_id", "meta") assert COLUMNS[-3:] == ("tenant_id", "meta", "thinking_observation")
class TestSQLiteRecorder: class TestSQLiteRecorder:
@@ -378,6 +383,28 @@ class TestSQLiteRecorder:
assert rows["r-zero"] == 0 # 上报了且确实没推理 assert rows["r-zero"] == 0 # 上报了且确实没推理
assert rows["r-none"] is None # 本次调用未上报 assert rows["r-none"] is None # 本次调用未上报
async def test_thinking_observation_column_round_trips(self, tmp_path):
"""issue #16: 三态裁定结果落库,事后才能按"这次到底推没推理"分组统计。
断言的是裸字符串 `"observed"` 而非枚举: 归一化在 emitter 侧完成
(`_record` `.value`),recorder 拿到的必须已经是 `str``StrEnum`
虽是 `str` 子类,asyncpg 的参数编码对子类不保证接受,而遥测写失败只
降级成一条 warning,PG 那一路会悄无声息地少一列数据
"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="t-obs", thinking_observation="observed")
await _record_minimal(recorder, call_id="t-absent", thinking_observation="absent")
await _record_minimal(recorder, call_id="t-unknown")
recorder.close()
rows = dict(
sqlite3.connect(tmp_path / "t.db")
.execute("SELECT call_id, thinking_observation FROM llm_calls")
.fetchall()
)
assert rows["t-obs"] == "observed"
assert rows["t-absent"] == "absent" # 观测到"确实没推理",与"看不出来"不是一回事
assert rows["t-unknown"] == "unknown"
async def test_sampling_column_round_trips(self, tmp_path): async def test_sampling_column_round_trips(self, tmp_path):
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。""" """issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True) recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
@@ -541,7 +568,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列) assert cols == _EXPECTED_COLUMNS # 22 → 25 个 recorder 字段(+ created_at 共 26 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall()) rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a" assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -583,7 +610,7 @@ class TestSQLiteCallerDimensionsAcceptance:
stale = sqlite3.connect(db) stale = sqlite3.connect(db)
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == ( assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
_EXPECTED_COLUMNS[:-2] _EXPECTED_COLUMNS[:-3]
) # 补列确实没成功,用例不是在只读库上空转 ) # 补列确实没成功,用例不是在只读库上空转
@@ -591,7 +618,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**: 旧表 22 INSERT 字段 + `created_at` = 23, 列数断言一律按**物理列数**: 旧表 22 INSERT 字段 + `created_at` = 23,
补齐后 24 + `created_at` = 25混用 INSERT 字段数与物理列数是本处最易错的地方 补齐后 25 + `created_at` = 26混用 INSERT 字段数与物理列数是本处最易错的地方
""" """
def _physical_columns(self, db: Path) -> list[str]: def _physical_columns(self, db: Path) -> list[str]:
@@ -630,7 +657,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path): async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 25""" """auto + 同款旧表: 现状回归,补列后物理列数 23 → 26"""
db = tmp_path / "auto_legacy.db" db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db) _make_pre_tenant_db(db)
@@ -639,10 +666,10 @@ class TestSQLiteSchemaMode:
recorder.close() recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 25 assert len(self._physical_columns(db)) == 26
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path): async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,25 个物理列齐全(设计 §4.2)。""" """manual 只管 ALTER,不管 CREATE: 全新库照建,26 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db" db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False) recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -866,6 +893,7 @@ class TestPostgresBackfillDiscipline:
"reasoning_tokens", "reasoning_tokens",
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -926,6 +954,7 @@ class TestPostgresTableProbe:
"reasoning_tokens", "reasoning_tokens",
"tenant_id", "tenant_id",
"meta", "meta",
"thinking_observation",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -1118,6 +1147,113 @@ class TestEmitterRecorderContract:
assert set(rec.rows[0]) == set(COLUMNS) assert set(rec.rows[0]) == set(COLUMNS)
class TestEmitterThinkingObservation:
"""issue #16: 三态裁定经 emitter 落库,且落的是**裸 str** 而非枚举实例。
类型断言不是洁癖: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str`
子类不保证接受,而遥测写失败只降级成一条 warningPG 那一路会静默少一列
数据,本地 SQLite 测试全绿也发现不了归一化因此固定在 emitter ,
`tenant_id`/`meta`/`sampling` 同一先例
"""
async def test_attempt_carries_the_verdict_as_a_plain_string(self):
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation=ThinkingObservation.OBSERVED),
error=None,
)
value = rec.rows[0]["thinking_observation"]
assert value == "observed"
assert type(value) is str # 不是 ThinkingObservation: 子类实例不得下沉到 recorder
async def test_a_bare_string_verdict_still_lands(self):
"""下游填裸 str 时**整行**不得丢失(遥测必录)。
`LLMResponse` 是无运行时校验的 frozen dataclass,
`LLMResponse(..., thinking_observation="observed")` 完全自然且 `==` 比较
照常成立; emitter 直接取 `.value`,这里会抛 `AttributeError` 并被
`_record` `except Exception` 吞成一条泛化 warning丢的不是这一列,
是整行,正是 1.3.0 那次"19 次调用一行未落"的同款形态
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation="observed"),
error=None,
)
assert len(rec.rows) == 1, "整行被吞了"
value = rec.rows[0]["thinking_observation"]
assert value == "observed"
assert type(value) is str
async def test_an_out_of_domain_verdict_degrades_but_keeps_the_row(self):
"""域外取值挡在落库前,但**降级不丢行**: 列的取值域由库守,代价不是整行。
直接 `ThinkingObservation(x).value` 会在这里抛 `ValueError`,同样被
`_record` `except Exception` 吞成丢整行那只修好了裸 str 一半,
口误值(大小写不符拼错)对测试替身同样自然故降级为 `unknown`
(对库而言本次确实判不出来)并单独告警,与缓存回放的方向选择一致
"""
rec = _MemoryRecorder()
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(thinking_observation="OBSERVED"), # 大小写不符即域外
error=None,
)
finally:
logger.remove(sink_id)
assert len(rec.rows) == 1, "整行被吞了"
assert rec.rows[0]["thinking_observation"] == "unknown"
hits = [m for m in messages if "OBSERVED" in m]
assert len(hits) == 1, f"域外取值必须单独告警: {messages}"
assert [m for m in messages if "遥测记录失败" in m] == []
async def test_cache_hit_replays_the_recorded_verdict(self):
"""缓存命中回放历史那次的裁定: 与 model/prompt_tokens 同一口径。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_cache_hit(
request=_REQ,
response=_resp(cache_hit=True, thinking_observation=ThinkingObservation.ABSENT),
)
assert rec.rows[0]["thinking_observation"] == "absent"
async def test_terminal_failure_records_unknown(self):
"""终态失败无响应可言,记 `unknown`——它恰好就是"观测不到",不撒谎。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_terminal_failure(
request=_REQ, call_id="c", latency_ms=1, error="dead"
)
value = rec.rows[0]["thinking_observation"]
assert value == "unknown"
assert type(value) is str
async def test_failed_attempt_records_unknown(self):
"""失败尝试(response=None)同理: 默认视图即 UNKNOWN。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None).emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=None,
error="boom",
)
assert rec.rows[0]["thinking_observation"] == "unknown"
class TestEmitterObservabilityFields: class TestEmitterObservabilityFields:
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。""" """issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
+313
View File
@@ -0,0 +1,313 @@
"""推理裁定与对账的行为测试(issue #16/#17 设计 §4-§5)。
判据来自 2026-08-25 实测(findings): MiniMax-M3 在开启档流式路径下返回 185 字符
推理正文却不上报 `completion_tokens_details`, qwen/deepseek 两者都报库因此
不能把任何单一信号当权威本组用例逐条钉死"哪个信号该赢"
"""
import pytest
from loguru import logger
from polygateway.providers import get_provider
from polygateway.thinking import (
DEFAULT_CAPABILITIES,
ThinkingCapability,
get_capability,
observe_thinking,
reconcile_thinking,
register_capability,
resolve_thinking,
)
from polygateway.types import ThinkingObservation
def _warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
return messages, sink_id
class TestObserveThinking:
"""三态裁定: 证据硬度决定优先级,无信号一律 UNKNOWN。"""
def test_reasoning_text_alone_proves_it_happened(self):
"""推理正文是事实本身: 上游不报 token 数也照样成立(M3 流式实测形态)。"""
assert (
observe_thinking(thinking="先解方程 x+y=35", reasoning_tokens=None)
is ThinkingObservation.OBSERVED
)
def test_blank_text_is_not_evidence(self):
"""纯空白正文不算证据: 网关响应是外部输入,truthy 判据会把空格计成推理(P5)。"""
assert (
observe_thinking(thinking=" \n\t ", reasoning_tokens=None)
is ThinkingObservation.UNKNOWN
)
def test_positive_token_count_proves_it_happened(self):
"""无正文但上游报了推理用量(qwen 非流式形态)。"""
assert observe_thinking(thinking="", reasoning_tokens=205) is ThinkingObservation.OBSERVED
def test_zero_token_count_is_positive_evidence_of_absence(self):
"""`0` 是"上报了且为零",与"没上报"语义不同,故是 ABSENT 而非 UNKNOWN。"""
assert observe_thinking(thinking="", reasoning_tokens=0) is ThinkingObservation.ABSENT
def test_no_signal_at_all_stays_unknown(self):
"""M3 非流式开启档的真实形态: 推理已计费却既无正文也无 token 数。
判成 ABSENT 就是伪装成"没推理"正是 issue #16/#17 的病根。
"""
assert observe_thinking(thinking="", reasoning_tokens=None) is ThinkingObservation.UNKNOWN
def test_text_outranks_a_zero_count(self):
"""转述与事实冲突时事实赢: 正文在,`reasoning_tokens=0` 不能翻案。"""
assert (
observe_thinking(thinking="想了想", reasoning_tokens=0) is ThinkingObservation.OBSERVED
)
@pytest.mark.parametrize("negative", [-1, -205])
def test_negative_token_count_is_not_evidence_of_absence(self, negative):
"""负数是坏数据,不是"上游明确上报未推理"这个最强的正面结论。
当前 transport 已在边界把负数归 `None`,所以这条走不通;但本函数的
docstring 自称"外部输入校验后使用",第二个 transport 直接填该值时,
`> 0 else ABSENT` 会给出一个方向相反的强结论函数自身必须闭合(P5)
"""
assert observe_thinking(thinking="", reasoning_tokens=negative) is (
ThinkingObservation.UNKNOWN
)
class TestThinkingObservationEnum:
def test_values_are_stable_strings(self):
"""取值进遥测落库,改名即历史数据断层。"""
assert ThinkingObservation.OBSERVED == "observed"
assert ThinkingObservation.ABSENT == "absent"
assert ThinkingObservation.UNKNOWN == "unknown"
def test_enum_lives_in_the_innermost_layer(self):
"""枚举必须定义在 `types.py`(最内层)。
它是 `LLMResponse` 的字段类型;定义在决策层 `thinking.py` 会让 `types.py`
反向 import 决策模块,违反 P7 依赖铁律(import-linter 契约执法)
"""
assert ThinkingObservation.__module__ == "polygateway.types"
@pytest.mark.parametrize("bogus", ["", "OBSERVED", "yes", "none"])
def test_unknown_strings_are_rejected(bogus):
"""非法值必须抛 ValueError: 缓存回放与遥测归一化都靠它识别域外取值(设计 §6)。
两处接住这个 ValueError **降级而非作废**(缓存复活内容 + UNKNOWN遥测
照常落行),但降级的前提是构造器真的会拒绝它一旦放行,域外取值就会一路
进到 `LLMResponse` 与遥测列里
"""
with pytest.raises(ValueError):
ThinkingObservation(bogus)
class TestThinkingCapability:
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
def test_registered_models_carry_evidence(self):
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
cap = get_capability(model)
assert cap is not None and cap.evidence.strip()
def test_m3_can_disable_but_m2x_cannot(self):
assert get_capability("MiniMax-M3").can_disable is True
assert get_capability("MiniMax-M2.7").can_disable is False
assert get_capability("MiniMax-M2.5").can_disable is False
def test_unregistered_model_is_unknown(self):
assert get_capability("some-brand-new-model") is None
def test_register_capability_is_pure(self):
table = register_capability("x-1", ThinkingCapability(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")
class TestReconcileThinking:
"""声明 × 观测对账(设计 §5): 矛盾出文案,不表态出 None。
文案本身是被断言对象判定与日志分离正是为此: 告警内容可直接比对,不必
去解析日志格式
"""
_CAP = ThinkingCapability(
can_disable=True, evidence="2026-08-02 实测 reasoning_effort=none 可关闭"
)
def test_off_but_observed_with_a_registered_capability_blames_the_table(self):
"""已登记却实测推理了 = 能力表漂移: 必须附 evidence 与更新指路。"""
msg = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
assert "2026-08-02 实测 reasoning_effort=none 可关闭" in msg
assert "register_capability" in msg
def test_off_but_observed_unregistered_never_claims_a_table_entry(self):
"""未登记模型没有"能力表声称"这回事——说它就是撒谎。"""
msg = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M9",
)
assert msg is not None
assert "MiniMax-M9" in msg
assert "能力表" not in msg
assert "register_capability" in msg
def test_registered_and_unregistered_wordings_differ(self):
registered = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
unregistered = reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.OBSERVED,
capability=None,
model="MiniMax-M3",
)
assert registered != unregistered
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_absent_is_a_contradiction(self, capability):
"""上游明确上报未推理: 这是唯一的正面证伪,与能力表登记与否无关。"""
msg = reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.ABSENT,
capability=capability,
model="qwen3.7-plus",
)
assert msg is not None
assert "qwen3.7-plus" in msg
@pytest.mark.parametrize("capability", [None, _CAP])
def test_on_but_unknown_admits_it_cannot_confirm(self, capability):
"""issue #17 的诚实版本: 明说"我注入了,但我看不见结果""""
msg = reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.UNKNOWN,
capability=capability,
model="MiniMax-M3",
)
assert msg is not None
assert "MiniMax-M3" in msg
def test_off_and_absent_stays_silent(self):
"""要求关闭 + 上游明确上报未推理 = 要求被满足,没有可报的矛盾。
这一格与 `test_off_and_unknown_stays_silent` 的沉默理由**不同**: 那里是
"没有证伪力",这里是"正面证实要求已满足"两者都必须沉默,漏测哪一格,
Phase 2 的判据写成 `is ABSENT` 之类的反向条件都不会被抓住
"""
assert (
reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.ABSENT,
capability=self._CAP,
model="qwen3.7-plus",
)
is None
)
def test_off_and_unknown_stays_silent(self):
"""UNKNOWN 没有证伪力: 拿它报警等于每次关闭调用都喊(M3 关闭档恒落此档)。"""
assert (
reconcile_thinking(
enable_thinking=False,
observation=ThinkingObservation.UNKNOWN,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
@pytest.mark.parametrize(
"observation",
[ThinkingObservation.OBSERVED, ThinkingObservation.ABSENT, ThinkingObservation.UNKNOWN],
)
def test_no_request_no_grievance(self, observation):
"""调用方不表态,就无从谈"违背""""
assert (
reconcile_thinking(
enable_thinking=None,
observation=observation,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
def test_on_and_observed_is_exactly_what_was_asked_for(self):
assert (
reconcile_thinking(
enable_thinking=True,
observation=ThinkingObservation.OBSERVED,
capability=self._CAP,
model="MiniMax-M3",
)
is None
)
+39
View File
@@ -14,6 +14,7 @@ from polygateway.types import (
LLMResponse, LLMResponse,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
ThinkingObservation,
TransportResult, TransportResult,
Usage, Usage,
) )
@@ -33,6 +34,18 @@ def _make_source(**overrides):
return SourceConfig(**base) return SourceConfig(**base)
class TestThinkingObservationLayering:
"""枚举必须留在最内层,别被后来的重构挪进决策模块。"""
def test_defined_in_types_not_in_thinking(self):
"""`LLMResponse` 拿它当字段类型,定义在 `thinking.py` 会让最内层反向依赖决策层。
这条不是风格洁癖: import-linter 会判红,但那要等代码写完才发现;本用例
把约束前移到类型层面
"""
assert ThinkingObservation.__module__ == "polygateway.types"
class TestLLMResponse: class TestLLMResponse:
def test_eleven_legacy_fields_positional(self): def test_eleven_legacy_fields_positional(self):
"""三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。""" """三项目 fake 的 11 参位置构造必须零改动成立(迁移兼容硬约束)。"""
@@ -75,6 +88,30 @@ class TestLLMResponse:
assert filled.model_reported == "MiniMax-Text-01-250321" assert filled.model_reported == "MiniMax-Text-01-250321"
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同 assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
def test_thinking_observation_defaults_to_unknown(self):
"""issue #16/#17: 默认必须是 UNKNOWN——"没信号"不得被伪装成"没推理"
默认值取 ABSENT 会让每个不填该字段的构造点(测试 fake其他 transport)
都在替上游做一个它没做过的声明,那正是本 issue 要消灭的静默错觉
"""
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
assert resp.thinking_observation is ThinkingObservation.UNKNOWN
filled = LLMResponse(
"c",
"t",
"m",
"p",
1,
2,
3,
None,
None,
False,
"cid",
thinking_observation=ThinkingObservation.OBSERVED,
)
assert filled.thinking_observation is ThinkingObservation.OBSERVED
def test_frozen(self): def test_frozen(self):
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid") resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
with pytest.raises(dataclasses.FrozenInstanceError): with pytest.raises(dataclasses.FrozenInstanceError):
@@ -251,6 +288,8 @@ class TestAuxTypes:
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动) # issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
assert s.cached_prompt_tokens is None and s.model_reported is None assert s.cached_prompt_tokens is None and s.model_reported is None
assert s.reasoning_tokens is None assert s.reasoning_tokens is None
# issue #16/#17: 不裁定的 transport 只能说"不知道",不能替上游说"没推理"
assert s.thinking_observation is ThinkingObservation.UNKNOWN
class TestOcrTypes: class TestOcrTypes:
+1 -1
View File
@@ -28,7 +28,7 @@ from polygateway import EmbeddingClient, GatewayClient, LLMResponse
from polygateway.config import _SOURCE_FIELDS from polygateway.config import _SOURCE_FIELDS
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
from polygateway.providers import register_provider from polygateway.providers import register_provider
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS from polygateway.telemetry.sqlite import COLUMNS as TELEMETRY_COLUMNS
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参) # 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}} _PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
+83 -9
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import re
import sqlite3 import sqlite3
import sys import sys
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
TABLE = "llm_calls" TABLE = "llm_calls"
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则 # 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
EXIT_OK = 0 EXIT_OK = 0
EXIT_USAGE = 1 EXIT_USAGE = 1
@@ -59,6 +65,11 @@ _EPILOG = """\
时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行; 时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行;
--older-than-days 0 "删除此刻之前的全部行" --older-than-days 0 "删除此刻之前的全部行"
--table: 本脚本只清理表 llm_calls, --table 只有 schema 一段可变(写成
--table <schema>.llm_calls)给了它,目标就由参数精确解析不再经
search_path 推断含点或引号的复杂标识符不支持,此时请不给 --table,
退回 search_path 解析那条路径
示例: 示例:
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\ python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
--older-than-days 90 # dry-run,只看会删什么 --older-than-days 90 # dry-run,只看会删什么
@@ -114,6 +125,11 @@ def _build_parser() -> _Parser:
action="store_true", action="store_true",
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给", help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
) )
parser.add_argument(
"--table",
metavar="SCHEMA.NAME",
help=f"仅 postgres: 把目标钉死为 <schema>.{TABLE},绕开 search_path 推断",
)
return parser return parser
@@ -150,10 +166,16 @@ def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
parser.error("--backend sqlite 不接受 --dsn") parser.error("--backend sqlite 不接受 --dsn")
if args.batch_size is not None: if args.batch_size is not None:
parser.error("--batch-size 仅用于 --backend postgres") parser.error("--batch-size 仅用于 --backend postgres")
if args.table is not None:
parser.error("--table 仅用于 --backend postgres:SQLite 的库文件即目标,无 schema 可消歧")
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None: def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。""" """Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。
`--table` 在此解析成 `args.table_schema`(未给则 None): 校验与解析放在同一处,
后面的执行路径就只面对一个已经合法的 schema ,不必再重复判断
"""
if args.dsn is None: if args.dsn is None:
parser.error("--backend postgres 需要 --dsn") parser.error("--backend postgres 需要 --dsn")
if args.path is not None: if args.path is not None:
@@ -164,6 +186,36 @@ def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
args.batch_size = 1000 args.batch_size = 1000
elif args.batch_size < 1: elif args.batch_size < 1:
parser.error("--batch-size 必须 >= 1") parser.error("--batch-size 必须 >= 1")
args.table_schema = None if args.table is None else _parse_table(parser, args.table)
def _parse_table(parser: _Parser, value: str) -> str:
"""校验 `--table SCHEMA.NAME` 并返回 schema 段;任何不合法形态退出 1。
**表名段为什么不可变**: 只校验"两段、非空"的话,一次手误 `--table audit.events`
就会让本脚本对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE
脚本的名字退出码 3 的分区提示README 的定位全都围绕遥测表写,它从未声称自己
是通用清理器;把这条校验去掉等于在一个拿 DELETE 权限跑的脚本上开静默的口子
"""
segments = value.split(".")
if len(segments) != 2:
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
schema, name = segments
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态
if not schema or not name:
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
if not _PLAIN_IDENTIFIER.fullmatch(schema):
parser.error(
f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/"
f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持,"
"这种情形请不给 --table,退回 search_path 解析那条路径。"
)
if name != TABLE:
parser.error(
f"--table 的表名段必须逐字等于 {TABLE}:本脚本只清理遥测表 {TABLE},"
f"不是通用清理器,当前: {value!r}"
)
return schema
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None: def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
@@ -240,7 +292,9 @@ def _quote(identifier: str) -> str:
return f'"{escaped}"' return f'"{escaped}"'
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int: async def _run_postgres(
dsn: str, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
) -> int:
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。""" """PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
try: try:
import asyncpg import asyncpg
@@ -257,7 +311,7 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr) print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
return EXIT_BACKEND return EXIT_BACKEND
try: try:
return await _purge_postgres(conn, cutoff, apply_, batch_size) return await _purge_postgres(conn, cutoff, apply_, batch_size, table_schema)
except asyncpg.PostgresError as exc: except asyncpg.PostgresError as exc:
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr) print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
return EXIT_BACKEND return EXIT_BACKEND
@@ -265,23 +319,41 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
await conn.close() await conn.close()
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int: async def _purge_postgres(
conn: Any, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
) -> int:
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。""" """已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来—— # 给了 --table 就用引号限定名精确解析(绕开 search_path),否则维持裸表名解析——
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。 # 后者走连接自己的 search_path,故无论哪条路都必须把解析结果打出来:"我删的到底是
# 哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
lookup = TABLE if table_schema is None else f"{_quote(table_schema)}.{_quote(TABLE)}"
target = await conn.fetchrow( target = await conn.fetchrow(
"SELECT n.nspname AS schema, c.relname AS name, " "SELECT n.nspname AS schema, c.relname AS name, "
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned " "EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace " "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass($1)", "WHERE c.oid = to_regclass($1)",
TABLE, lookup,
) )
if target is None: if target is None:
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr) # 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
# search_path 找不到则是连接配置的事。
if table_schema is None:
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
else:
print(
f"显式指定的表 {table_schema}.{TABLE} 不存在或当前角色不可见。"
"注意: PG 中未加引号建的标识符在 catalog 里是小写。",
file=sys.stderr,
)
return EXIT_BACKEND return EXIT_BACKEND
schema, name = target["schema"], target["name"] schema, name = target["schema"], target["name"]
qualified = f"{_quote(schema)}.{_quote(name)}" qualified = f"{_quote(schema)}.{_quote(name)}"
print(f"目标表: {schema}.{name}") print(f"目标表: {schema}.{name}")
if apply_ and table_schema is None:
# 只在 --apply 时提示: dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途。
print(
"注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。"
)
if target["partitioned"]: if target["partitioned"]:
print( print(
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n" f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
@@ -347,7 +419,9 @@ def main(argv: Sequence[str] | None = None) -> int:
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}") print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
if args.backend == "sqlite": if args.backend == "sqlite":
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum) return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size)) return asyncio.run(
_run_postgres(args.dsn, cutoff, args.apply, args.batch_size, args.table_schema)
)
if __name__ == "__main__": if __name__ == "__main__":