43 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The library stops at the column plus a documented policy template. It
must never enable RLS itself -- with no matching policy that is
default-deny, which would silently fail every write for the two
downstreams that are not multi-tenant.
2026-08-17 05:55:40 -04:00
iomgaa 4351e2be73 docs: the package link API works now, drop the manual workaround
Measured 201 on POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/
PolyGateway during the 1.2.0 release. The note saying it 404s and must
be done through the web UI would have sent the next release down a
manual path that is no longer needed.
2026-08-16 23:41:44 -04:00
iomgaa 17dcff41c3 Merge branch 'feat/issue-10-error-body-retention' 2026-08-16 23:34:20 -04:00
iomgaa fa4a7e220b test: make the 429 red line actually catch its violation
Verifier mutation test: flipping _translate_429 to parse the summary
left all 824 tests green. The padding was one long string value, so the
cut landed inside it - and head-and-tail retention kept the trailing
error object, leaving the summary parseable. Many keys put the cut
between structural tokens, where the summary stops being valid JSON.
Mutation now fails as it should. Also splits OCR 429 out on its own.
2026-08-16 06:50:50 -04:00
iomgaa 658086e2c0 docs: release 1.2.0 and unpin downstream from the 1.1 series
Issue #10 Task 6. The install pin moves from ==1.1.* to >=1.2,<2 - left
alone, everyone following the README would have stayed silently on
1.1.2 without this fix and without a warning. Telemetry field count
re-measured via inspect.signature: still 22.
2026-08-16 06:22:13 -04:00
iomgaa 9dada0be9d test: prove a rejected call's reason reaches the telemetry table
Issue #10 Task 5, the acceptance claim. Before the fix this asserted
against 'qwen_1 请求被拒: 400' and failed on the first substring - which
is exactly what the downstream batch was left with. Uses the real body
from the issue, and checks the trailing code too, since a head-only cut
would drop the one field you quote when chasing the provider.
2026-08-16 06:17:10 -04:00
iomgaa a3f4cc323f feat: keep the gateway's words on the OCR branches too
Issue #10 Task 4: the OCR side said only 'HTTP 404'. The issue reported
the chat path, but the batch that lost its 400 was reading tables - the
same blind spot, one transport over. Reuses the shared summarizer.
2026-08-16 06:14:07 -04:00
iomgaa 0edb9d397a feat: keep the gateway's words on every non-2xx chat branch
Issue #10 Task 3: five branches each built their own message, so adding
the summary would have meant five copies. Table-driven classification
composes it in one place instead, and the 429 split still parses the
untruncated body - reading the summary would demote an oversized
insufficient_quota to a plain rate limit and stop force_open.
2026-08-16 06:07:29 -04:00
iomgaa 484900d300 feat: add the single summarizer for HTTP error bodies
Issue #10 Task 2: head-and-tail rather than a head-only cut, because the
code and request_id that let you chase the provider sit at the very end
of a JSON error body. Cap 2048 follows k8s client-go for the same job.
2026-08-16 06:03:26 -04:00
iomgaa e302247022 feat: let every gateway error carry what the gateway said
Issue #10 Task 1: a rejected call's reason had nowhere to live. The
field goes on the base class because these errors all come from one HTTP
response - which class it is and what the peer said are orthogonal.
2026-08-16 06:01:38 -04:00
iomgaa 1489aab95d docs: add the missing imports to the plan's key interfaces
Codex review: the code blocks reference httpx and PolyGatewayError, but
neither module imports them today. A zero-context implementer copying
them verbatim would stall on F821.
2026-08-16 05:57:07 -04:00
iomgaa c2dd4a1cf4 docs: plan the implementation for issue #10 2026-08-16 05:50:37 -04:00
iomgaa 1801289277 docs: mark the issue #10 design approved 2026-08-16 05:34:46 -04:00
iomgaa 7462cad166 docs: widen the body cap to 2048 and keep the tail
The 500-char head-only rule came from a single sample. k8s client-go
caps the same thing at 2048; reprlib keeps head and tail because the
text is meant to be read. Gateway error bodies are JSON whose code and
request_id sit at the very end, so a head-only cut drops exactly what
you need to chase the provider. Version pinned at 1.2.0, which forces
the README install pin off ==1.1.*.
2026-08-16 05:24:30 -04:00
iomgaa 3cbe8aab91 docs: register the issue #10 design in the research wiki 2026-08-16 05:12:14 -04:00
iomgaa 707f8f7317 docs: pin the truncation rule to arithmetic after Codex review
"Truncate at cap and append the ellipsis" admits both 501 and 500 total
length; the two would desync test assertions from the telemetry length
promise. Cap is now the total including the marker.
2026-08-16 05:09:06 -04:00
iomgaa 10fbc5441e docs: design how the gateway's refusal survives the transport layer
Issue #10: the 400 body dies in _status_to_error, and telemetry only
writes str(exc), so adding a field alone would not make the refusal
queryable after the fact. Design keeps the summary in both the message
and a new base-class body_text, across every non-2xx branch and both
transports.
2026-08-16 05:03:33 -04:00
iomgaa 114fc8b1b3 Merge branch 'chore/packaging-metadata' 2026-08-07 21:48:15 -04:00
iomgaa 4f1ab21562 chore: give the package page a body and repo links
1.1.2 went out with an empty description on the registry page: without a
readme field there is no long_description, and twine only warns about
that -- it does not block the upload. Add readme and project.urls, and
record the wider lesson in the release procedure: a release is done when
the pages a downstream user actually opens look right, not when the local
steps go green. Also adds the missing step for creating a Release, which
is why the Releases page sat empty through eight tags.
2026-08-07 21:48:15 -04:00
iomgaa 2be89c47d8 Merge branch 'fix/issue-9-telemetry-ddl-probe' 2026-08-07 11:23:38 -04:00
iomgaa 7c60199680 chore: release 1.1.2
README first, per the release procedure: the 1.1.* pin still covers this
version, the 22-field count re-checked with inspect.signature, and the
telemetry row now states that an existing table needs no schema CREATE
privilege.
2026-08-07 11:23:29 -04:00
iomgaa 2e028d38f2 fix: probe for the telemetry table before creating it
PostgreSQL checks the schema CREATE privilege before the IF NOT EXISTS
existence test, so an account with only table-level INSERT was denied on
CREATE TABLE IF NOT EXISTS even though the table was right there and
writable. The denial set _failed and the whole recorder went no-op for
the process lifetime, silently: 150+ calls downstream lost their latency,
token and cost rows with nothing but one warning to show for it.

The probe is the direct fix. The larger fix is the criterion: structural
degradation now means "provably cannot write" (pool creation failed, or
the table is absent and cannot be created), not "something threw during
init" -- a probe or acquire failure just skips the row and retries on the
next call.

SQLite stays as it is on purpose. Measured: it short-circuits the
statement at parse time, so it passes even under another connection's
EXCLUSIVE lock or on a read-only file. A probe there would buy nothing;
the docstring now says so to keep symmetry-minded future edits away.
2026-08-07 11:21:33 -04:00
iomgaa c2e9f5396c docs: write down the release procedure that keeps getting skipped
Bumping the version is not releasing. 1.0.6 and 1.1.0 both got a version
bump and a changelog entry but were never uploaded, so the registry sat at
1.0.5 and downstream could not install any of those fixes.

The ordering matters in one non-obvious way: README has to be correct
before the build, because sdist freezes whatever is there at that moment.
That is exactly how 1.1.1 shipped with a stale README. The install pin is
called out by name since it is the easiest line to forget and the most
damaging to leave wrong.

Also records where the Gitea token actually lives — tea's config, not
.pypirc — after that misreading led to a wrong "no credentials" claim.
2026-08-06 12:44:06 -04:00
iomgaa d2cb8770df docs: correct the telemetry field count and document backpressure
Three README drifts, none of them about issue #8 alone:

- the telemetry row said 18 fields; record_llm_call takes 22 (verified by
  inspect.signature). ports.py claimed 20 in its own docstring, so both
  records of the same fact were stale.
- LLMResponse.cost is hardcoded to None on the chat path (retry.py:519).
  Cost only ever reaches telemetry. The old doc site named this as a known
  trap, so the capability row now says it outright.
- backpressure had no row at all, which is what issue #8 was about.
2026-08-06 12:33:45 -04:00
iomgaa 80bc94c42d docs: point the install pin at 1.1.x
The pin still said ==1.0.*, which caps downstream at 1.0.5 and hides
every fix since. Now that 1.1.1 is actually in the registry the pin can
move; it was missed when 1.1.0 was tagged.
2026-08-06 12:24:44 -04:00
iomgaa bb69ecf0da Merge branch 'feat/issue-8-stall-budget'
stall 判定改为非生产性等待口径(issue #8)并发布 1.1.1。
2026-08-06 12:12:42 -04:00
42 changed files with 3419 additions and 91 deletions
+102
View File
@@ -1,5 +1,107 @@
# Changelog # Changelog
## 1.2.1(2026-08-18)
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。
### 新增
- **四个公共方法各增两个 keyword-only 参数 `tenant_id``meta`**,都带默认值 `None`,**既有调用点零改动**: `GatewayClient.chat()``EmbeddingClient.embed()``OcrClient.recognize_text()``OcrClient.parse_layout()`。issue 只诉求前两条链路;OCR 经同一个 `TelemetryEmitter` 写**同一张表**,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
| 列 | Postgres | SQLite |
|---|---|---|
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
- **老表经现有 `_BACKFILL` 机制自动补列**(先探测再 `ALTER`,失败只逐行降级),补列后**老行的 `tenant_id` 读出是空串而非 NULL**。这个区别是刻意的: PG 的 RLS `USING` 表达式返回 false **或 null** 的行都不可见、且静默跳过不报错,所以 NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**;哨兵空串则显式可查,`COUNT(*) WHERE tenant_id = ''` 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
- **`TelemetryRecorder.record_llm_call` 由 22 字段扩为 24**(`inspect.signature` 实测),`ChatRequest` 同步新增两个带默认值的字段。`meta``json.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False)` 序列化,空 dict 落 `'{}'` 而非 NULL。
### 校验规则(超限报错,不静默丢弃)
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
| 项 | 规则 |
|---|---|
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
| `meta` 键数 | ≤ **16** |
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
| `meta` 值 | 仅 `str` / `int` / `float` / `bool`,嵌套需调用方自行序列化;字符串值 ≤ **256** 字符;`float` 必须有限,`nan` / `inf` 报错(它们不是合法 JSON,PG 的 JSONB 会拒收) |
报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。**超限一律报错**,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。
### 不变
- **`tenant_id``meta` 都不进缓存 key**。租户级的缓存隔离由既有的 `cache_namespace` 负责,重复进 key 只会让全部存量缓存冷启动;且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
- 既有 22 列的列名与列序、`ON CONFLICT (call_id) DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**,下游 `except` 写法不受影响。
- 缓存命中行与终态失败行同样带维度,且读的是**本次** `request` 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
### 边界: 库只交付列,RLS 与索引由下游执行
**库不会执行 `ENABLE` / `FORCE ROW LEVEL SECURITY`,也不会建任何索引。** 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 `CREATE POLICY`(并建 `(tenant_id, created_at)` 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 `tenant_id` 等值谓词,它必然是前导列);**不执行则 `tenant_id` 只是一个可查、可过滤的普通列,没有任何数据库层强制**。
不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。
RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。
### 升级提示
- **升级无需任何代码改动**: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 `{}`
- README 的安装 pin 由 `>=1.2,<2` 收紧为 `>=1.2.1,<2`。按 `>=1.2,<2` 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.0 就没有租户维度**。
- README 的配置参考表此前漏列了源级 `MISSING_DONE``EXTRA_BODY`(正文别处却引用了后者)、`{SCOPE}__QUOTA_FULL`、embedding 专用键、`PGW_CACHE_BACKEND``memory` 档与三个可选 `PGW_*` 键,本版按 `config.py``_SOURCE_FIELDS``_load_pgw` 逐项补齐。代码零变更。
## 1.2.0(2026-08-16)
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
根因是三条留存通道同时为空: `_status_to_error` 手上握着 `body_text` 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 `str(exc)`,即 message——所以**只给异常加字段并不能让它进遥测表**,必须两者都做。
### 新增
- **四分类错误新增 `body_text` 字段**(加在 `PolyGatewayError` 基类): 非 2xx 响应体的摘要。与 `ResultInvalidError.raw_text` 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(`GatewayUnavailableError` 一族)恒为空串: 它们没有单一响应体可言。
- **同一份摘要同时进入异常 message**,故 SQLite/Postgres 遥测的 `error` 列里直接可查,下游不必为此单独埋点。
### 行为变更
- **非 2xx 的 message 末尾追加 ` | {响应体摘要}`**,覆盖两个 transport 的**全部**分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 `insufficient_quota`),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 `force_open` 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
- 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 **2048 字符**(对齐 Kubernetes client-go 同场景的 `maxUnstructuredResponseTextBytes`)。超长时**保留头 1400 + 尾 600**并记下省略字数——JSON 错误体的 `code` / `request_id` 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
- 遥测 `error` 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。
### 不变
- **状态码 → 错误分类的映射逐条未动**(ARCHITECTURE §6.2 表),`retry_after_s` 解析、429 免重试预算、`insufficient_quota` 细分全部保持——429 的类型判定仍解析**未截断的原文**,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 `force_open`
- 异常类型树、`str(exc)` 之外的字段、遥测 22 字段与列序、DDL 全部未变。**错误面零变更**,下游 `except` 写法不受影响。
- 400 仍按确定性失败处理(不重试不换源)。**但请注意**: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 `prompt_tokens=0` 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 `body_text` 现在给了下游自行区分的判据。
### 升级提示
README 的安装 pin 由 `==1.1.*` 改为 `>=1.2,<2`。**仍按 `==1.1.*` 安装的下游会静默停在 1.1.2**,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。
- 打包元数据补齐: `readme``[project.urls]`。1.1.2 及之前的包在 registry 页面上**没有任何说明正文**(缺 `readme` 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
## 1.1.2(2026-08-07)
Postgres 遥测撞上建表权限就整体判死的问题(issue #9)。**最小权限部署会静默丢掉全部遥测**: 应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,初始化的 `CREATE TABLE IF NOT EXISTS` 被拒 → recorder 永久 no-op,业务调用一切正常,只留一行 warning。下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用耗时/token/成本因此全部丢失,且事后无法补回。
根因是 **PostgreSQL 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断**(PG 16.14 实测: 同一连接 `INSERT` 通过、`to_regclass` 看得见表,该 DDL 照样被拒)——与 issue #3 修过的 `ALTER TABLE` 是同一类问题,当时只修了补列那一半。
### 行为变更
- **PG 侧建表前先 `to_regclass` 探测,表已存在就一条 DDL 都不发**。探测不需要任何权限,且与 `INSERT` 走同一套 search_path 解析(比裸 DDL 更准: 裸 `CREATE TABLE` 落在首个**可建**的 schema,可能与写入命中的不是同一张表)。表不存在时才建,新建表列已齐全,顺带跳过补列。
- **"结构性失能"的判据收窄为「确定写不进去」**,不再是「初始化时出过异常」。仅两种情形仍永久降级为 no-op: 建池失败(重试要在业务路径上内联吞掉连接超时)、表确定不存在且建不出来(后续 INSERT 必然全败)。探测失败、取连接失败改为**只跳过本条并 warning,下次调用重新准备**——初始化瞬间的一次抖动不再让整个进程失遥测。
- 日志措辞随之细分: `建池失败` / `建表探测失败(跳过本条,下次重试)` / `建表失败(表不存在,记录无处可落)`,原先一律是 `初始化失败`
### 不变
- SQLite 侧**一行未改**。实测其对已存在的表在解析期就把 `CREATE TABLE IF NOT EXISTS` 短路掉(另一连接持 `BEGIN EXCLUSIVE`、文件 `chmod 444` 时该语句均通过,而同条件的 `INSERT` 分别报 database is locked / readonly database),没有同款风险;加探测零收益,故有意不对称,只在 docstring 钉死实测结论。
- 遥测端口签名、22 字段、列序、`ON CONFLICT DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**。
### 升级提示
若你的部署此前为了绕开本问题给应用账号授了 `CREATE ON SCHEMA`,现在可以收回——表存在时库不再需要该权限。
## 1.1.1(2026-08-06) ## 1.1.1(2026-08-06)
stall 判定改为非生产性等待口径(issue #8)。`timeout_s ≥ stall_window_s` 时,**一次耗满超时的请求就会让整个 scope 被判死,配置的重试次数一次都用不上**——而且没有任何报错或 warning,配置方以为自己配了 3 次重试。`stall_window_s` 默认 300 恰是个很容易被 `TIMEOUT_S` 追平的值,"只配 timeout、不配 stall"这种最常见的写法正好踩中。 stall 判定改为非生产性等待口径(issue #8)。`timeout_s ≥ stall_window_s` 时,**一次耗满超时的请求就会让整个 scope 被判死,配置的重试次数一次都用不上**——而且没有任何报错或 warning,配置方以为自己配了 3 次重试。`stall_window_s` 默认 300 恰是个很容易被 `TIMEOUT_S` 追平的值,"只配 timeout、不配 stall"这种最常见的写法正好踩中。
+25
View File
@@ -78,6 +78,31 @@ make ci # 只读验证(check + test)
### 4.4 Git 工作流 ### 4.4 Git 工作流
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。 - 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
> [!CRITICAL]
> **发布 = 合并 + push + tag + 构建 + 上传 registry。只 bump 版本号不叫发布。**
> 教训: 1.0.6 与 1.1.0 都完成了版本号 bump 与 CHANGELOG,却从未上传,registry 长期停在 1.0.5——下游 `pip install` 拿不到任何修复,且无人发现。
按顺序执行,**构建之前**必须先改完所有文档:
| # | 动作 | 要点 |
|---|---|---|
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件 |
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
| 9 | **建 Release + 挂仓库 + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);挂仓库走 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`(**2026-08-16 实测返 201 可用**,此前记录的"该实例 link API 返 404、只能网页手动"已过时);随后打开包页面确认有正文与仓库链接 |
> [!CRITICAL]
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI 元数据不含仓库字段,故不会自动挂到 `PolyGateway/packages`,需在包页面手动 Link to a repository。
### 4.5 配置管理 ### 4.5 配置管理
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。 - 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。 - 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
+54 -7
View File
@@ -15,9 +15,11 @@
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After | | 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 | | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 18 字段;SQLite / Postgres 后端;按价格表折算成本;多模态内容摘要落库不存原图 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 | | 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` | | OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
| Embedding | 分批、维度校验、与 chat 同一治理栈 | | Embedding | 分批、维度校验、与 chat 同一治理栈 |
@@ -30,7 +32,7 @@
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]==1.0.*" "polygateway[redis,postgres,structured]>=1.2.1,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -80,7 +82,7 @@ async def main() -> None:
await client.aclose() # 归还连接与治理后端资源 await client.aclose() # 归还连接与治理后端资源
``` ```
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。 `chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
### 3. OCR 与 Embedding ### 3. OCR 与 Embedding
@@ -111,6 +113,45 @@ except RequestRejectedError:
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败 ... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
``` ```
### 5. 多租户与自定义维度
```python
resp = await client.chat(
messages,
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
)
```
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
**库只提供列,不启用 RLS、不建索引。** 要数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
```sql
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。
三个陷阱,每一个的失败形态都是**静默的**:
| 陷阱 | 后果 |
|---|---|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
| policy 必须同时写 `USING``WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 |
## 错误模型(四分类) ## 错误模型(四分类)
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码: 一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
@@ -124,6 +165,8 @@ except RequestRejectedError:
预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。 预算耗尽/全源熔断时抛 `GatewayUnavailableError` 族(`CircuitOpenError` / `AllSourcesExhausted`),携带 `scope` / `reason` / `retry_after_s` / `per_source_reasons`,供任务队列做延期重投。
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
### 哪些异常会到达调用方 ### 哪些异常会到达调用方
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明: 上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
@@ -145,12 +188,16 @@ except RequestRejectedError:
| 键形态 | 作用 | | 键形态 | 作用 |
|---|---| |---|---|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/TRUST_ENV | | `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) | | `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) | | `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) | | `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
| `PGW_CACHE_BACKEND` | `none` / `redis`(`PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`) | | `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none``PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) | | `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。 `SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
+9 -1
View File
@@ -4,8 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.1.1" version = "1.2.1"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"httpx>=0.27", "httpx>=0.27",
@@ -31,6 +34,11 @@ dev = [
"import-linter>=2.0", "import-linter>=2.0",
] ]
[project.urls]
Homepage = "https://gitea.iomgaa.online/iomgaa/PolyGateway"
Changelog = "https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/CHANGELOG.md"
Issues = "https://gitea.iomgaa.online/iomgaa/PolyGateway/issues"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+20 -2
View File
@@ -363,6 +363,16 @@ flowchart TB
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW``{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。 **`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW``{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None``meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
**校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}``pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
**两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。
**维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。
**库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。
--- ---
## 6. 错误模型 ## 6. 错误模型
@@ -396,6 +406,10 @@ flowchart TB
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) | | **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` | | 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
**400 在中转拓扑下的语义提醒**(同上): 第三方 API 中转服务自身抖动时也会回 400,从状态码上与供应商的"输入非法"无法区分(下游实测: 同一份字节重发 15 次全成功,失败那次 `prompt_tokens=0`、耗时远低于任何成功调用,即请求在推理开始前被挡)。本表**不改** 400 → `RequestRejectedError` 的映射——直连供应商时重试只会白烧配额,且改默认语义等于让所有直连用户为一种部署形态买单;库改为把判据(`body_text`)交给下游自行区分。
### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer) ### 6.3 "坏结果 ≠ 坏服务"(ResultInvalidError 语义,继承 CHSAnalyzer)
由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239` 由输入内容决定的**确定性失败**(这张图就是解析不出表格、这段输出就是修不成 JSON):服务是健康的,换源重试只会白烧配额。因此熔断器记成功、不换源、异常上抛消耗业务侧的失败预算。出处:`CHSAnalyzer governance.py:237-239`
@@ -471,11 +485,15 @@ 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**。 **必录字段**(继承三项目 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**
**`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。
(`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,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。新列在 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)。 **`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
(`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)。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder` - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 - **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
@@ -0,0 +1,238 @@
# HTTP 错误响应体留存设计(Issue #10)
- **日期**: 2026-08-16
- **来源**: Gitea Issue #10(下游 1050 张医学影像批处理,1 张收到 400 被判确定性失败;事后无从查证原因。基于 1.1.2 源码核查)
- **状态**: **已批准(2026-08-16)**,待 `writing-plans`
- **触发档位**: 强制(`errors.py` 属最内层内核,新增公共字段即变更库对下游的承诺)
- **方案范围**: 人类明确要求单一方案(2026-08-16),故本文不列平行备选,仅在 §6 记录被否决路线及否决理由(体例沿用 Issue #7/#8 设计)
## 1. 目标与非目标
| | 内容 |
|---|---|
| **G1** | 网关拒绝一次调用时,**它说了什么必须可事后查证**——库自己的遥测表里就能查到,不依赖下游额外埋点 |
| **G2** | 留存口径覆盖 transport 层**全部**非 2xx 分支与**全部** transport(chat / embedding / stream / OCR),杜绝"只修 400 → 下次 401 复发" |
| **G3** | 摘要文本单点规范化(折叠空白 + 截断 + 截断标记),message 与结构化字段**取同一份串**,两处永不打架 |
| **G4** | 不改变任何状态码 → 错误分类的映射(ARCHITECTURE §6.2 表原封不动),下游 `except` 写法零影响 |
| **非目标** | 不改 400 的治理语义(不重试不换源,见 §5.2);不新增遥测列(见 §6.1);不新增配置项;不做错误分类可插拔(见 §6.4);不顺手修 `_status_to_error``operation` 硬编码缺陷(见 §5.4) |
### 1.1 Issue 前提的两处修正(按 1.1.2 源码核实)
| Issue 原文 | 实际情况 |
|---|---|
| 建议方向一「让异常带上截断后的响应体……就能让下游把它记进日志和遥测」 | **只做这一半解决不了 Issue 自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`middleware/retry.py:558``middleware/telemetry.py:89``telemetry/sqlite.py:43``error TEXT` 列),即**异常 message**。新增字段不会进库的遥测表;下游说的"写进遥测表"是他们自己的埋点。故本设计**两件都做,且以 message 为主**(§3.3) |
| 缺陷范围 = 400 分支 + 4xx 兜底 | 实为 **6 处同构**:`openai_compat._status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 的两支(读了 body 判 `insufficient_quota`,但 message 仍不带),以及 `monkey_ocr._classify_status:74-88` 的**全部**分支(message 只有 `HTTP {status}`)。Issue 场景是"读表格",极可能正落在 OCR 路径 |
## 2. 根因:诊断信息在翻译层被丢弃,而遥测只看 message
`_status_to_error`(`transports/openai_compat.py:131-143`)手上握着 `body_text`,却只把它用于 429 的类型细分,翻出的异常与 message 都不携带它。响应体在这一层之后**不再存在于进程任何位置**:该模块无 logger(grep `logger|loguru` 零命中),异常类无字段,遥测只写 message。
三条留存通道同时为空,是"永久查不到"的完整解释:
| 通道 | 现状 | 本设计后 |
|---|---|---|
| 日志 | 模块无 logger | 仍无(§6.2:不加日志) |
| 异常字段 | 无承载处 | `body_text`(§3.1) |
| 库遥测 `error` 列 | 只有 `"{源名} 请求被拒: 400"` | message 携带摘要(§3.3) |
## 3. 选定方案
### 3.1 内核:`PolyGatewayError` 基类新增 `body_text`
```python
class PolyGatewayError(Exception):
def __init__(self, message, *, source_name=None, status_code=None,
operation=None, body_text: str = "") -> None:
```
**加在基类而非 `RequestRejectedError`**:这些错误全部由同一个 HTTP 响应翻译而来,"对方说了什么"与"它属于哪一类"正交。只给一个子类加,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 一次人类门。
与既有 `ResultInvalidError.raw_text`(`errors.py:77`)的界限必须在 docstring 钉死,否则两个"原文字段"必然被混用:
| 字段 | 语义 | 来源 |
|---|---|---|
| `body_text` | **非 2xx** 的 HTTP 错误响应体摘要——对方**拒绝**你的理由 | transport 翻译层 |
| `raw_text` | **2xx** 但内容不可解析时的模型输出原文 | 结构化解析层 |
`GatewayUnavailableError` 一族继承到一个恒空的 `body_text` 不是噪音:scope 级失败本就"没有单一响应体可言",空串是对这件事的如实表达。
### 3.2 共享单元:`transports/_http_errors.py`(新建,~40 行)
两个 transport 各有自己的状态码分类逻辑(OCR 无 429 细分,有意保留,见 `monkey_ocr.py:53-54`),但**摘要口径必须同一份**,否则就是下一个"只修一半"。两函数:
| 函数 | 职责 | 关键防御 |
|---|---|---|
| `summarize_body(text) -> str` | 折叠空白 → 按 §3.4 的机械规则截断 | 空/空白入参返回 `""` |
| `response_body(response) -> str` | 从 `httpx.Response` 取已缓冲文本 | `ResponseNotRead` → 返回 `""`,**绝不触发网络读** |
- **折叠空白不是洁癖**:错误体常是缩进 JSON,直接拼进 message 会让一行日志炸成多行、遥测列不可读。
- **截断必须留标记**:不标记,读的人分不清"网关只说了这么多"和"库切的"。
- **`response_body` 的防御是硬要求**:`monkey_ocr._classify_status` 只拿得到 `httpx.HTTPStatusError`,若某天 OCR 走 stream 请求,`.text` 会抛 `ResponseNotRead`,把一次可分类的 4xx 变成泄漏的 httpx 异常——**违反"一切失败必须落入四分类"铁律**。诊断信息缺失绝不能升级为崩溃(降级方向,§4.2)。
放在 `transports/` 私有模块而非 `errors.py`:职责是"HTTP 响应 → 领域错误"的工具,放内核会稀释 `errors.py` 的单一职责(P3)。两个 transport 同 import 一个私有模块,不构成 transport 之间的互相依赖,import-linter 的 layers 契约(同层 `|` 独立性)不受影响。
### 3.3 翻译层:表驱动收口,message 与字段共用一份摘要
`_status_to_error` 现在是五个分支各拼各的 message,新增摘要意味着五处重复。改为**分类表 + 单点拼装**,代码反而变短:
```
summary = summarize_body(body_text) # 全函数只算一次
ctx = {..., "body_text": summary} # 字段
429 → _translate_429(source, body_text, headers, ctx) # 需原文判 type,单列
其余 → cls, label = _STATUS_MAP 查表 → cls(_compose(source, label, status, summary), **ctx)
```
message 形态:`"{源名} {标签}: {状态码} | {摘要}"`;**摘要为空时不拼后缀**,避免出现悬空的 ` | `。分隔符取 ` | ` 而非既有的 `: `,让"库的话"与"网关的话"一眼可分。
**429 也拼,不设例外**:例外就是下一个复发点。`insufficient_quota` 那支尤其需要(配额细节全在 body 里);普通限速 body 通常很短。代价是高频限速场景遥测 `error` 列变长,由 `_ERROR_BODY_CAP` 兜住。
`monkey_ocr._classify_status` 同款处理:`summary = summarize_body(response_body(exc.response))`,message 追加同一后缀,`ctx` 带上字段。
### 3.4 常量取值
**机械规则(实现与测试逐字照此)**:
```
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
折叠空白后 len ≤ 2048 → 原样返回
否则 → s[:1400] + f"…(略 {len(s) - 2000} 字)…" + s[-600:]
```
> 规则必须写成算术而非叙述:"截断至 cap 并补标记"能同时被读成总长 2048 与 2049,两者会让测试断言与遥测长度承诺对不上(Codex 审查 2026-08-16 提出)。
**头尾保留而非头部硬切**(2026-08-16 调研决策)。截断的对象是**结构化 JSON 错误体**,信息分布头重尾也重:人话(`message`)在前,机器可判的 `type` / `code` / `param` / `request_id` 在后。Issue 给出的真实样本即 `"code":"invalid_parameter_error"` 收尾——头部硬切正好切掉向网关方追查时唯一有用的那部分。省略标记记下**被省略的字符数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
按**字符**而非字节切:多字节字符不会被切成半个(Sentry 曾为按字节切开 issue #1691),且 `error TEXT` 列无定长约束,无需字节口径。
### 3.4.1 取值依据:同场景开源实践
| 项目 | 场景 | 上限 | 保留策略 |
|---|---|---|---|
| **Kubernetes client-go** `rest/request.go` | **读 HTTP 错误体生成错误信息**(与本设计同构) | `maxUnstructuredResponseTextBytes = 2048` | 头部硬切 |
| OpenAI Python SDK `_exceptions.py` | 异常对象持有 body | **不截断**(内存对象,不落库) | — |
| Sentry Python `strip_string` | 事件写入前 trim | `max_value_length`,2.34.0 前默认 1024 | 头部 + `...`,另用 metadata 记原长 |
| Elastic APM | 长字段 | keyword 1024 / long field 10000 | 截断带省略号 |
| Python 标准库 `reprlib` | 给人读的长字符串 | `maxstring` | **头 + 尾,中间省略** |
**2048 对齐 k8s client-go**——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。初稿的 500 仅以 issue 的单个样本(约 160 字符)为据,是拿一个样本定上限,已废弃。头部硬切在 k8s/Sentry 成立是因为它们截的是任意文本;本设计截的是结构化 JSON,故取 `reprlib` 的头尾策略。
遥测代价:纯 ASCII 约 2KB/条,纯中文最多约 6KB/条;5xx 重试 3 次即一次调用最多约 18KB。批处理场景(1050 次调用、5% 失败)约 300KB,`TEXT` 列可忽略。
message 与 `body_text` **共用同一变量**,不设两个长度:两份不同长度会让"遥测里看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
### 3.5 改动清单
| 文件 | 改动 |
|---|---|
| `errors.py` | 基类新增 `body_text` 字段 + 与 `raw_text` 的界限 docstring |
| `transports/_http_errors.py` | **新建**:`summarize_body` / `response_body` / `_ERROR_BODY_CAP` |
| `transports/openai_compat.py` | `_status_to_error` 表驱动重写;`_translate_429``ctx` |
| `transports/monkey_ocr.py` | `_classify_status` 带摘要 |
| `errors.py` docstring + `ARCHITECTURE.md` §6.2 | 中转拓扑下 400 的提醒(§5.2) |
| `README.md:34` | 安装 pin `==1.1.*``>=1.2,<2`(§5.3,发布前置,漏改则下游拿不到本修复) |
三个调用点(`openai_compat.py:402` embed、`:417` stream、`:509` 非流式)**签名不变**,无需改动。
## 4. 非功能维度
### 4.1 并发与取消
新增全部是纯函数与数据字段,无状态、无锁、无 IO、不引入 `await``response_body` 只读已缓冲字节,`ResponseNotRead` 时直接返回空串而**不发起网络读**——否则会在错误路径上凭空插入一次可能挂住的 IO。`CancelledError` 路径逐字不变。
### 4.2 降级方向
响应体不可得(未读缓冲 / 解码失败 / 空体)→ `body_text=""`,**静默降级,绝不报错**。诊断信息属可观测性,按库铁律与缓存/遥测同档:缺了降级,不得把一次本可正确分类的失败变成不可分类的崩溃。流式路径的 `(await resp.aread()).decode("utf-8", errors="replace")`(`:416`)已是这个口径,保持。
### 4.3 幂等与重复
纯函数,同输入同输出。`summarize_body` 对自身输出再调用一次是幂等的:输出总长恒为 `2000 + len(标记) ≤ 2048`(标记形如 `…(略 N 字)…`,8 + N 的位数,现实中远不足 48),且不含需折叠的空白,故第二次调用走"原样返回"分支,不会出现标记被反复嵌套。
### 4.4 持久化与原子性
不新增表、不改 DDL、不动遥测端口的 22 字段与列序。摘要经既有 `error TEXT` 列落盘,原子性由既有单行写入保证。
### 4.5 安全与体积
- **响应体可能回显请求内容**(部分网关的 `error.param` 会带违规字段值)。截断 + 空白折叠是主要止血手段;字段 docstring 须写明"可能包含请求回显,已截断"。库不做内容脱敏——库不知道下游哪些字段敏感,猜测式脱敏只会同时丢掉诊断价值与安全性。
- **本设计不放大既有的读取风险**:`_complete_stream:416``aread()` 对错误响应体无大小上限(超大错误体可打爆内存),该风险今天已经存在(读完即丢),留存后只是更显眼。**不夹带修复**,见 §5.4。
## 5. 错误处理、语义与边界
### 5.1 错误分类
不改任何映射。`body_text` 是**旁路数据**,不参与任何治理判定——不影响重试、换源、熔断计数、AIMD、限流结算。这是本设计能与 ARCHITECTURE §6.1/§6.2 零冲突的根本原因。
### 5.2 400 语义:不改行为,补文档
Issue 报告了一个有说服力的观察:同字节 15 次重发全部成功、`prompt_tokens=0`、耗时 2996ms 远低于同批 631 次成功调用的最快值 7366ms——说明那次 400 来自中转服务自身抖动,而非"你的输入有问题"。
**仍不改分类**:400 重试对直连供应商是纯浪费(确定性坏输入,重试只烧配额并拖延失败);"中转也回 400"是**部署拓扑**引入的信息损失,库从状态码无从分辨。默认改为可重试 = 让所有直连用户为一种部署形态买单,且推翻已冻结的公共契约。
**但本设计本身就是对这个观察最好的答复**:body 留存后,下游能自己区分——中转抖动的 400 体与供应商 `invalid_request_error` 体形态不同。库不替下游做判断,而是把判断所需的信息交出去。配套文档动作:`RequestRejectedError` docstring 与 ARCHITECTURE §6.2 各加一句"经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类"。
### 5.3 兼容性
`LLMResponse` 一族的"字段只增不删不改名"约束(ARCHITECTURE §5.1)同样适用于异常。本次是**纯新增关键字参数且带默认值**:既有构造点、既有 `except` 写法、既有 `str(exc)` 消费方全部不受影响。message 文本变化不构成破坏——现有测试对这些 message 无格式依赖(仅 `test_openai_compat.py:558` match 源名)。
版本 **1.2.0**(公共类型新增字段属 minor;2026-08-16 人类定夺)。
**发布时必须同步改 README 的安装 pin**:`README.md:34` 现为 `"polygateway[redis,postgres,structured]==1.1.*"`,发 1.2.0 后照此命令安装的下游会**静默停在 1.1.2**——无报错、无警告,与 CLAUDE.md §4.4.1 点名的"极易漏改"完全同款(registry 长期停在 1.0.5 即此类事故)。本次改为 **`>=1.2,<2`**,把"每发一个 minor 就要通知三个下游改 pin"这一反复出现的麻烦一次性消除。此项列入实现计划的发布前置步骤,不是发布日的临时动作。
### 5.4 有意不夹带的两项(建议单开 issue)
| 项 | 说明 |
|---|---|
| `_status_to_error``operation` 硬编码 `"chat"`(`:134`),而 `embed()` 也调它(`:402`) | embedding 的 HTTP 错误在遥测里被标成 `operation="chat"`,是既有数据正确性缺陷,与本 issue 无关 |
| `_complete_stream:416``aread()` 无大小上限 | 恶意/故障网关的超大错误体可打爆内存,属独立的健壮性问题 |
两项都在本次重构触及的函数附近,但修它们既不服务 G1-G4,也各自需要独立的行为讨论——按反 gold-plating 铁律留给独立 issue。
## 6. 被否决的路线
### 6.1 给遥测端口加一列(22 → 23 字段)
最"正统"的结构化留存,但成本极不相称:端口 Protocol 签名变更 + SQLite/Postgres 双后端 DDL 迁移 + 下游已有表的 ALTER + 列序契约测试全线改动——为一个诊断串付出一次跨三项目的迁移。而复用既有 `error TEXT` 列可达成同样的可查证性。
### 6.2 只在 `_status_to_error` 打一条 WARNING 日志(Issue 方向二)
不采纳为**主**手段:日志与遥测是两套留存,日志轮转后仍然查不到,而 Issue 的痛点恰是"事后"。且库铁律要求库不擅自向下游日志流写入高频内容(4xx/5xx 在批处理下可能极高频)。message 携带摘要已让 loguru 侧的下游在捕获点自然拿到同一份信息,再加一条独立日志属重复留存。
### 6.3 截断放在异常构造器内
构造器自动规范化更"防遗漏",但会让下游自建异常时传入的文本被悄悄改写,违反 P4;且 message 里的摘要仍需在翻译层单独算一次,反而出现两条规范化路径。选定方案在翻译层算一次、两处共用,更简且更显式。
### 6.4 错误分类映射可插拔(provider profile 注入 classifier)
Issue 的中转 400 场景确实指向这个方向,但当前只有一个使用方且他们已用自己的兜底分类解决。`ProviderProfile`(`providers.py:17-45`)目前也没有这个扩展点,加它是新子系统级的设计。YAGNI:等第二个使用方提出。
## 7. 测试策略(先失败后通过)
**验收主张**:一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要。这条端到端断言直接对应 Issue 的痛点,是本设计成立与否的唯一硬判据;其余为覆盖性用例。
| # | 用例 | 覆盖 |
|---|---|---|
| 1 | **端到端遥测**:mock transport 返回 400 + 真实样本体 → 断言 recorder 收到的 `error` 含摘要 | G1 |
| 2 | 参数化状态码(400 / 401 / 404 兜底 / 429 普通 / 429 `insufficient_quota` / 500)→ 断言 message 含摘要且 `exc.body_text` 非空,**分类与既有断言逐一不变** | G2, G4 |
| 3 | 超长体 → 前 1400 字符与原文头部逐字相同、**末 600 字符与原文尾部逐字相同**、中段为 `…(略 N 字)…` 且 N 等于实际省略数;`body_text` 与 message 中的摘要逐字相同 | G3 |
| 3b | 长度恰为 2048 / 2049 的体 → 前者原样无标记,后者走头尾保留(边界) | §3.4 |
| 3c | **尾部关键字段可见**:以 issue 的真实样本尾部 `"code":"invalid_parameter_error"}}` 构造超长体 → 断言该串出现在摘要中 | §3.4 头尾决策的验收 |
| 3d | 摘要对自身幂等(再摘要一次不嵌套标记) | §4.3 |
| 4 | 多行缩进 JSON → 折叠为单行 | G3 |
| 5 | 空体 / 纯空白体 → 不拼悬空分隔符,`body_text == ""` | §3.3 |
| 6 | 非 JSON 体、非 UTF-8 字节 → 不抛异常,分类不变 | §4.2 |
| 7 | 流式错误路径(`_complete_stream` 415-417)同样带摘要 | G2 |
| 8 | `monkey_ocr._classify_status` 同款(含 `ResponseNotRead` 时降级为空串而非抛出) | G2, §4.2 |
| 9 | embedding 路径(`:402`)HTTP 错误带摘要 | G2 |
`tests/unit/test_errors.py:29` 现有的"四类构造形态"参数化用例需扩展 `body_text` 默认值断言(默认 `""`、可传入、`GatewayUnavailableError` 一族恒空)。
## 8. 人类定夺记录(2026-08-16)
| 议题 | 定夺 |
|---|---|
| 摘要上限与保留策略 | 初稿 500 + 头部硬切被否:上限提至 **2048**(对齐 k8s client-go 同场景先例),策略改为**头 1400 + 尾 600 + 省略字数标记**——人类指出"有用的信息可能只在后半部分",经调研证实 JSON 错误体的 `code`/`request_id` 确实收尾(§3.4、§3.4.1) |
| 429 是否设例外 | **不设**,一律拼摘要(§3.3) |
| 版本 | **1.2.0**,并同步把 README pin 由 `==1.1.*` 改为 `>=1.2,<2`(§5.3) |
@@ -0,0 +1,230 @@
# 调用方自定义维度设计(issue #11)
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
---
## 1. 需求与边界
### 1.1 issue 原始诉求
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()``embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。
**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
### 1.2 本次放大的范围(2026-08-17 人类决策)
issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:
- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
- 保留期与访问控制不在本次范围(issue 第 4 条)。**已另开 issue #12**(2026-08-17)。
**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit``ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。
### 1.3 明确不做
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。
---
## 2. 关键既有事实(设计必须服从的约束)
| # | 事实 | 出处 | 对本设计的约束 |
|---|---|---|---|
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |
---
## 3. 备选方案对比
### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)
`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。
**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。
### 方案 B: 纯 `meta` JSON,不提任何列
最小改动、最通用。**否决**,两条独立的实证:
**RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
**planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)
最通用,下游不必等库发版。**否决**,理由分三层:
**业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
**本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。
**与端口契约冲突**`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。
**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。
### 推荐
**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
---
## 4. 设计细节
### 4.1 公共 API
四个公共方法对称新增两个 keyword-only 参数(F2/F3):
`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)``parse_layout(image, *, ..., 同两参数)`
**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。
**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)
| 项 | 规则 | 依据 |
|---|---|---|
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
| `meta` value: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
**非有限 float 必须在入口拒绝(Codex 审查发现)**`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()``embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223``validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。
### 4.3 内部流转: 三条独立链路
`ChatRequest``tenant_id: str | None = None``meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。
`TelemetryEmitter._record` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**`EmbeddingClient` 不经过 chat 洋葱: `embed()``_embed_batch()``_attempt()``_emit()`,而 `_emit()``embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。
**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()``_call()``_attempt()``_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。
**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
### 4.4 存储层
两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):
- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`
**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。
**库不自动建索引**`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
### 4.5 RLS: 库止步于列 + 模板
**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:
**default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。
**库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。
**权限不对等**`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
**SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING``WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
ON llm_calls (tenant_id, created_at);
```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。
---
## 5. 旧版行为审计
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
| 契约 | 处置 |
|---|---|
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
---
## 6. 非功能维度
**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
---
## 7. 错误分类与测试策略
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
**测试策略**(合并前需先失败后通过的证据):
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
**三条链路各测一遍**——`chat()``embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text``parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。
非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})``ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。
**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
---
## 8. 开放问题(留待人类审批时确认)
1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
2. `tenant_id` 长度上限 128 同为推断值。
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次。**已另开 issue #13**(2026-08-17)。
@@ -0,0 +1,62 @@
---
type: design
node_id: design:issue10-error-body-retention
title: "HTTP 错误响应体留存(Issue #10)"
date: 2026-08-16
---
# HTTP 错误响应体留存(Issue #10)
**来源**: Gitea issue #10(CHSAnalyzer3 现场,1050 张影像批处理中 1 张 400 被判确定性失败、事后无从查证)|**范围**: `errors.py` + 两个 transport|**全文**: `designs/2026-08-16-issue10-error-body-retention-design.md`|**相关**: [[design:issue8-stall-budget]](同为下游实测反馈驱动的治理修正)
## 问题
网关拒绝一次调用时,它说的话在 transport 翻译层被丢弃,进程中不再有任何副本:该模块无 logger、异常类无承载字段、库遥测只写 message。三条留存通道同时为空,故"永久查不到"。
## 根因(Issue 前提的关键修正)
Issue 建议"给异常加 `body_text` 字段,下游就能记进遥测"——**只做这一半解决不了它自己陈述的痛点**。库的逐次遥测写的是 `error=str(exc)`(`retry.py:552``telemetry.py``sqlite.py``error TEXT` 列),即**异常 message**;新增字段不进库的遥测表。下游说的"写进遥测表"是他们自己的埋点。
缺陷范围也大于 issue 所述:实为 6 处同构——`_status_to_error` 的 400 / 4xx 兜底 / 401·403 / 5xx 四支,`_translate_429` 两支(读了 body 判类型却不带),以及 `monkey_ocr._classify_status` 全部分支(message 只有 `HTTP {status}`)。Issue 场景"读表格"极可能正落在 OCR 路径。
## 选定方案
**摘要在翻译层算一次,同一份串同时进 message 与新增的基类字段**——前者解决"事后可查"(走既有遥测列,零 DDL),后者解决下游结构化留存。
| 决策 | 理由 |
|---|---|
| 字段加在 `PolyGatewayError` 基类,非 `RequestRejectedError` | 这些错误全由同一个 HTTP 响应翻译而来,"对方说了什么"与"属于哪一类"正交;只加子类,下次给 `SourceDeadError` 加又是一次公共 API 变更 + 人类门 |
| 与 `ResultInvalidError.raw_text` 的界限写进 docstring | `body_text` = 非 2xx 的拒绝理由;`raw_text` = 2xx 但不可解析的模型输出。两个"原文字段"不钉死必被混用 |
| 新建 `transports/_http_errors.py` 共用摘要口径 | 两 transport 各有分类逻辑(OCR 无 429 细分,有意保留),但摘要必须同一份,否则就是下一个"只修一半" |
| `_status_to_error` 改表驱动 | 五分支各拼各的 message,加摘要即五处重复;查表 + 单点拼装后代码更短 |
| 摘要 = 折叠空白 + 总长 ≤ 2048,超出则**保留头 1400 + 尾 600**,中段记省略字数 | 折叠是因错误体常是缩进 JSON,拼进 message 会炸成多行。2048 对齐 k8s client-go 的 `maxUnstructuredResponseTextBytes`(唯一同场景先例);**头尾保留取自 `reprlib`**——JSON 错误体的 `code`/`request_id` 收尾,头部硬切正好切掉向网关追查唯一有用的部分(人类质疑 + 2026-08-16 调研,初稿的 500 + 头部硬切已废) |
| 429 不设例外 | 例外就是下一个复发点;`insufficient_quota` 那支的配额细节全在 body 里 |
| 400 治理语义不动,只补文档 | 见下 |
## 400 语义:不改行为,本次修复本身就是答复
Issue 给出有力证据(同字节 15 次重发全成功、`prompt_tokens=0`、2996ms 远低于同批成功最快的 7366ms),说明那次 400 来自中转服务抖动而非坏输入。**仍不改分类**:400 重试对直连供应商是纯浪费,而"中转也回 400"是部署拓扑引入的信息损失,库从状态码无从分辨;默认改可重试 = 让所有直连用户为一种部署形态买单。
但 body 留存后**下游能自己区分**——中转抖动体与供应商 `invalid_request_error` 体形态不同。库不替下游判断,把判断所需的信息交出去。配套在 docstring 与 ARCHITECTURE §6.2 加一句中转拓扑提醒。
## 被否决的备选
| 备选 | 否决理由 |
|---|---|
| 遥测端口加一列(22 → 23 字段) | 端口签名变更 + 双后端 DDL + 下游 ALTER + 列序契约全线改动,为一个诊断串付出跨三项目迁移;复用既有 `error` 列可达成同样可查证性 |
| 只打一条 WARNING 日志(issue 方向二) | 日志轮转后仍查不到,而痛点恰是"事后";且 4xx/5xx 在批处理下可能极高频 |
| 截断放进异常构造器 | 下游自建异常的文本被悄悄改写(违反 P4),且 message 侧仍需单独算一次,反出现两条规范化路径 |
| 错误分类映射可插拔 | Issue 场景确实指向它,但当前只有一个使用方且已用自己的兜底分类解决;`ProviderProfile` 无此扩展点,加它是子系统级设计。YAGNI |
## 有意不夹带(留独立 issue)
- `_status_to_error``operation` 硬编码 `"chat"`,而 `embed()` 也调它 → embedding 的 HTTP 错误在遥测里被标成 chat。
- `_complete_stream``aread()` 对错误响应体无大小上限,超大错误体可打爆内存(既有风险,留存后更显眼)。
两项都在本次触及的函数附近,但均不服务本 issue 目标,且各需独立行为讨论。
## 验收主张
一次 400 调用后,注入的 recorder 收到的 `error` 串含网关响应体摘要——这条端到端断言是本设计成立与否的唯一硬判据,其余用例为覆盖性(状态码参数化、截断边界 2048/2049、**尾部关键字段可见**、空白折叠、空体不拼悬空分隔符、非 UTF-8 不炸、流式路径、OCR 路径含 `ResponseNotRead` 降级)。
**发布约束**:版本 1.2.0,且 README 安装 pin 必须由 `==1.1.*` 改为 `>=1.2,<2`——否则照 README 安装的下游静默停在 1.1.2,拿不到本修复。
@@ -0,0 +1,26 @@
---
type: design
node_id: design:issue11-caller-dimensions
title: "调用方自定义维度设计(issue #11)"
date: 2026-08-17
---
# 调用方自定义维度设计(issue #11)
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开,**已建 issue #12**。
- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
- **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
- **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN``IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
- **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
- **库止步于列 + policy 模板,绝不自动 ENABLE RLS**: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = **无声全局丢数据**——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 `CREATE POLICY`;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
- **哨兵值而非 NULL**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见且静默跳过**,故 NULL 的 `tenant_id` 不是"未归属"而是**对所有人永久不可见的黑洞**。用 `NOT NULL DEFAULT ''` 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
- **超限报错而非静默丢弃**: Langfuse 的"value 超 200 字符直接丢弃"**不抄**,违反 P5。报错点在 `chat()` 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 `overlay` 保护键先例)。
- **不进缓存 key**: `cache_namespace` 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
- **被否决备选**: 纯 `meta` JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 `cache_namespace` 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");`tenant_id` 混在 `meta` 里当约定 key(拼错不报错,静默降级成普通维度)。
- **审查留痕(Codex,2026-08-17)**: 报 4 项,逐条核实后**全部采纳**。① `embed()` 路径覆盖不足——`EmbeddingClient` 不走 chat 洋葱,`_emit()``embedding.py:360` 现场构造 `ChatRequest`,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② **非有限 float 会击穿"序列化不可达"论断**——`json.dumps``nan` 写成 `NaN` 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 `math.isfinite` + 序列化 `allow_nan=False` 双层收口;③ 校验入口表述只写 `chat()`,与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 `FORCE`/`USING`+`WITH CHECK`/`NULLIF(current_setting(...))` 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
- **另开议题(已建 issue #13)**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
@@ -0,0 +1,62 @@
---
type: design
node_id: design:issue9-telemetry-ddl-probe
title: "建表前先探测,判死只认「确定写不进去」"
date: 2026-08-07
---
# 建表前先探测,判死只认「确定写不进去」
**来源**: Gitea issue #9(CHSAnalyzer3 现场)|**范围**: `telemetry/postgres.py` 单模块,无独立 plan(小改动自判)|**相关**: [[design:response-observability-fields]](issue #3 修的是同一个坑的另一半)
## 问题
应用账号有表级 `INSERT`、表也已存在,但没有 schema 的 `CREATE` 权限时,`_ensure_ready()``CREATE TABLE IF NOT EXISTS` 被拒 → `_failed = True`**整个进程遥测永久 no-op**。业务调用一切正常,只留一行 warning,从外部完全看不出异常;下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用数据因此全丢且无法补回。
## 根因
**PostgreSQL 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断**(`RangeVarGetAndCheckCreationNamespace()` 先 aclcheck 后查 relid)。这与 issue #3`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 是同一类问题——当时只修了补列那一半,建表这一半原样留着,于是同一账号形态下"补列失败只丢一行日志接着干活,建表失败却把整个 recorder 判死"。
**实测(PostgreSQL 16.14,临时角色只授 `SELECT, INSERT ON llm_calls`)**:
| 语句 | 结果 |
|---|---|
| `SELECT to_regclass('llm_calls')` | 非 NULL(表就在那儿) |
| `CREATE TABLE IF NOT EXISTS llm_calls (...)` | **被拒 InsufficientPrivilegeError: permission denied for schema** |
| `INSERT INTO llm_calls ...` | 通过 |
| `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 被拒 must be owner(即 issue #3 那条) |
## 选定方案
两条,第二条才是治本的那条:
1. **表存在就绝不发 DDL**。探测走 `to_regclass`(不需要任何权限,且与 `INSERT` 走同一套 search_path 解析——裸 `CREATE TABLE` 落在首个**可建**的 schema,可能与写入命中的不是同一张表,故探测优先反而更准)。表不存在才建;新建表列已齐全,顺带跳过补列。
2. **"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」**:
| 情形 | 处置 | 理由 |
|---|---|---|
| 建池失败 | 永久 no-op | 重试要在业务调用路径上内联吞掉 connect 超时 |
| 表存在 | 不发 DDL,只补列(失败仅 warning) | 本 issue 的直接修复 |
| 表不存在 → 建表成功 | 就绪,跳过补列 | 新建表列已齐 |
| 表不存在 → 建表失败 | 永久 no-op | 后续 INSERT 必然全败,重试无意义、日志纯噪音 |
| 探测/取连接失败 | 只跳过本条,下次调用重试 | 瞬时抖动,判死代价远大于多一次往返 |
**SQLite 侧有意不对称**:实测其对已存在的表在**解析期**就把 `CREATE TABLE IF NOT EXISTS` 短路掉——另一连接持 `BEGIN EXCLUSIVE`、或文件 `chmod 444` 时该语句均通过(同条件下 `INSERT` 与新表名建表分别报 database is locked / readonly database),既不抢写锁也不检查可写性。故 PG 侧的坑在此不存在,加探测零收益。**需要对称的是保证(表存在就不该因建表失败而失能),不是代码**;结论已钉进 `sqlite.py` 模块 docstring,防止后人为"对称"加回来。
## 被否决的备选
| 备选 | 否决理由 |
|---|---|
| 只加探测,`_failed` 语义不动(issue 原方案) | 治标。初始化瞬间的 DB 抖动、一次 `pool.acquire` 失败、search_path 配错仍会让整个进程永久失遥测——同一个开关,换个触发口 |
| 除建池外一律不判死 | 方向最统一,但表真的不存在时每次调用都发一条注定失败的 INSERT + 一条 warning(150 次调用 = 150 行噪音),而这种情形是**可确定判定**的,没必要留活路 |
| 捕获 `InsufficientPrivilegeError` 特判放行 | 按异常类型打补丁,漏一种错误码就复发;探测是把"该不该发这条 DDL"判断在前,与错误面无关 |
| SQLite 侧同步加探测 | 实测证明零收益,属为对称而对称的 gold-plating |
## 遗留
**SQLite 的窄缝**:表不存在 + 构造瞬间库被排他锁(多进程共库)→ `__init__` 里的建表失败 → recorder 永久失能。修它要把 SQLite 也改成 lazy 重试结构,超出本 issue 范围,记此备查。
## 测试证据
- 单测 `TestPostgresTableProbe`(5 例,fake conn):表存在不发 DDL / DDL 被拒仍照常 INSERT 且 `_failed` 不置位 / 表缺失则建表且不补列 / 表缺失且建不出来才判死 / 探测失败下次重试。
- 集成 `TestLeastPrivilegeDeployment`(真实 PG,临时 schema + 临时角色,teardown 删净):先钉死"该角色确实建不了表"这条库外事实,再验两行记录照常落库。**修复前该用例复现 issue 原文那行 warning 并失败**。
+29
View File
@@ -150,6 +150,21 @@
"id": "plan:issue8-stall-budget-plan", "id": "plan:issue8-stall-budget-plan",
"label": "issue #8 实施计划: stall 非生产性等待口径", "label": "issue #8 实施计划: stall 非生产性等待口径",
"type": "plan" "type": "plan"
},
{
"id": "design:issue10-error-body-retention",
"label": "HTTP 错误响应体留存(Issue #10)",
"type": "design"
},
{
"id": "plan:issue10-error-body-retention-plan",
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
"type": "plan"
},
{
"id": "plan:issue11-caller-dimensions",
"label": "调用方自定义维度实现计划(issue #11)",
"type": "plan"
} }
], ],
"links": [ "links": [
@@ -271,6 +286,20 @@
"relation": "implements", "relation": "implements",
"evidence": "T1-T6 实施该设计,含 §3.6 订正", "evidence": "T1-T6 实施该设计,含 §3.6 订正",
"added": "2026-08-06T14:58:01.673693+00:00" "added": "2026-08-06T14:58:01.673693+00:00"
},
{
"source": "plan:issue10-error-body-retention-plan",
"target": "design:issue10-error-body-retention",
"relation": "implements",
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
"added": "2026-08-16T09:50:57.830855+00:00"
},
{
"source": "plan:issue11-caller-dimensions",
"target": "design:issue11-caller-dimensions",
"relation": "implements",
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
"added": "2026-08-17T10:09:08.967997+00:00"
} }
] ]
} }
+12 -3
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-08-06 14:58 UTC > 自动生成,更新时间:2026-08-17 10:09 UTC
## design (25) ## design (30)
- [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`
@@ -15,9 +15,12 @@
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design` - [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design` - [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design` - [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
- [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`
@@ -25,8 +28,10 @@
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
- [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`
- [推理开关能力建模与 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 #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (12) ## finding (12)
@@ -43,7 +48,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 (21) ## plan (25)
- [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`
@@ -54,6 +59,8 @@
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params` - [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan` - [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget` - [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [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`
@@ -62,8 +69,10 @@
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## schema (1) ## schema (1)
+12
View File
@@ -94,3 +94,15 @@
- [2026-08-06 14:58 UTC] 新增 plan: issue #8 实施计划: stall 非生产性等待口径 (plan:issue8-stall-budget-plan) - [2026-08-06 14:58 UTC] 新增 plan: issue #8 实施计划: stall 非生产性等待口径 (plan:issue8-stall-budget-plan)
- [2026-08-06 14:58 UTC] 新增边: plan:issue8-stall-budget-plan --implements--> design:issue8-stall-budget - [2026-08-06 14:58 UTC] 新增边: plan:issue8-stall-budget-plan --implements--> design:issue8-stall-budget
- [2026-08-06 14:58 UTC] 重建索引: 61 篇页面 - [2026-08-06 14:58 UTC] 重建索引: 61 篇页面
- [2026-08-07 15:20 UTC] 新增 design: 建表前先探测,判死只认「确定写不进去」 (design:issue9-telemetry-ddl-probe)
- [2026-08-07 15:20 UTC] 重建索引: 62 篇页面
- [2026-08-07 15:11 UTC] 重建索引: 62 篇页面
- [2026-08-16 09:09 UTC] 新增 design: HTTP 错误响应体留存(Issue #10) (design:issue10-error-body-retention)
- [2026-08-16 09:12 UTC] 重建索引: 64 篇页面
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
@@ -0,0 +1,259 @@
# 实现计划: HTTP 错误响应体留存(Issue #10)
- **设计**: `research-wiki/designs/2026-08-16-issue10-error-body-retention-design.md`(**已批准 2026-08-16**)
- **分支**: `feat/issue-10-error-body-retention`
- **目标**: 网关拒绝一次调用时,它说的话必须能在库自己的遥测表里被事后查到。
- **方案概述**: transport 翻译层把 HTTP 错误响应体折叠空白并按头尾策略摘要,**同一份串**同时拼进异常 message(经既有 `error` 列落遥测)与新增的基类字段 `body_text`(供下游结构化留存)。覆盖两个 transport 的全部非 2xx 分支。不改任何状态码→分类的映射。
- **涉及技术**: Python 3.11 / httpx / pytest。无新增依赖。
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移——错误分类映射逐条不变,保真体现为"既有分类断言全部保留、无一条被改写"(Task 3/4 验收项)。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/errors.py` | 改 | 基类 `PolyGatewayError` 新增 `body_text` 字段;与 `raw_text` 的界限 docstring;`RequestRejectedError` 补中转拓扑提醒 |
| `src/polygateway/transports/_http_errors.py` | **新建** | 摘要口径单一实现:`summarize_body` / `compose_message` / `response_body` + 三个常量 |
| `src/polygateway/transports/openai_compat.py` | 改 | `_status_to_error` 表驱动重写;`_translate_429``ctx` |
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_classify_status` 带摘要 |
| `tests/unit/test_http_error_body.py` | **新建** | 摘要单元的纯函数用例(截断边界、头尾保留、折叠、幂等) |
| `tests/unit/test_openai_compat.py` | 改 | 状态码参数化断言 message + 字段;超长 `insufficient_quota` 回归 |
| `tests/unit/test_monkey_ocr.py` | 改 | OCR 分支同款 + `ResponseNotRead` 降级 |
| `tests/unit/test_errors.py` | 改 | `body_text` 默认值与可传性 |
| `tests/integration/test_governance_stack.py` | 改 | **端到端验收**:400 调用后 SQLite `error` 列含摘要 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` / `research-wiki/ARCHITECTURE.md` | 改 | 文档与 1.2.0 版本号 |
## 关键接口(跨任务消费,必须逐字一致)
```python
# src/polygateway/transports/_http_errors.py
from __future__ import annotations
import httpx # response_body 的类型与 ResponseNotRead 都来自它
_ERROR_BODY_CAP = 2048 # 字符(非字节),含省略标记在内的最终总长上限
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
def summarize_body(text: str) -> str:
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。"""
collapsed = " ".join(text.split())
if len(collapsed) <= _ERROR_BODY_CAP:
return collapsed
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
def compose_message(message: str, summary: str) -> str:
"""摘要非空才拼后缀,避免悬空分隔符。"""
return f"{message} | {summary}" if summary else message
def response_body(response: httpx.Response) -> str:
"""取已缓冲的响应文本;未读缓冲一律降级空串,绝不触发网络读。"""
try:
return response.text
except httpx.ResponseNotRead:
return ""
```
```python
# src/polygateway/errors.py
class PolyGatewayError(Exception):
def __init__(
self,
message: str,
*,
source_name: str | None = None,
status_code: int | None = None,
operation: str | None = None,
body_text: str = "",
) -> None:
```
```python
# src/polygateway/transports/openai_compat.py
# 既有 errors 导入(:18-23)须补入 PolyGatewayError —— 当前只导了四个子类,
# 直接写 _classify 的返回注解会让 ruff 报 F821 未定义名。
from polygateway.errors import (
PolyGatewayError, # ← 新增
RequestRejectedError,
ResultInvalidError,
SourceDeadError,
TransientError,
)
from polygateway.transports._http_errors import compose_message, summarize_body
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
"""状态码 → (错误类, message 标签);映射与 1.1.2 逐条相同。"""
if status in (401, 403):
return SourceDeadError, "凭据失效/欠费"
if status == 400:
return RequestRejectedError, "请求被拒"
if status >= 500:
return TransientError, "瞬时错误"
return RequestRejectedError, "客户端错误"
def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
) -> Exception:
summary = summarize_body(body_text) # 全函数只算一次
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"body_text": summary,
}
if status == 429:
return _translate_429(source, body_text, headers, ctx) # 传**原文**,见下
cls, label = _classify(status)
return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
```
> **实现红线**:`_translate_429` 判 `insufficient_quota` 必须解析**未截断的原文** `body_text`,不得改用 `summary`。摘要会破坏 JSON 结构,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`——那是把一个诊断改进变成治理 bug。Task 3 有专门的回归用例钉死这条。
## 任务清单
### - [ ] Task 1: 内核新增 `body_text` 字段
**改**: `src/polygateway/errors.py`
- `PolyGatewayError.__init__` 按上文签名新增 `body_text: str = ""`,存为实例属性。
- 类 docstring 增补与 `ResultInvalidError.raw_text` 的界限:`body_text` = 非 2xx 的 HTTP 错误响应体摘要(对方拒绝的理由);`raw_text` = 2xx 但内容不可解析时的模型输出。并写明"可能包含请求回显,已截断"。
- **不动** `TransientError` / `SourceDeadError` / `RequestRejectedError` / `ResultInvalidError` / `GatewayUnavailableError` 的任何既有签名与行为。
**测试**(`tests/unit/test_errors.py`,扩展 `:29` 的四类构造形态参数化):
- 四个 transport 错误类默认 `body_text == ""`;显式传入后可读回。
- `GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` / `GovernanceBackendError``body_text` 恒为 `""`(它们不经 HTTP 响应翻译)。
**验收**: 新增字段不改变任何既有异常的 `str()` 输出。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_errors.py -v` → 全 PASS。
### - [ ] Task 2: 共享摘要单元
**新建**: `src/polygateway/transports/_http_errors.py`(按上文"关键接口"逐字实现,**含其中的 `import httpx`**,加中文模块/函数 docstring 解释**为什么**折叠空白、为什么头尾保留、为什么 `response_body` 必须降级)
**新建测试**: `tests/unit/test_http_error_body.py`
| 用例 | 断言 |
|---|---|
| 短体原样 | `summarize_body('{"a":1}') == '{"a":1}'` |
| 空白折叠 | 多行缩进 JSON → 单行,无连续空格 |
| 空 / 纯空白入参 | 返回 `""` |
| 长度恰 2048 | 原样返回,无标记 |
| 长度 2049 | 走头尾策略 |
| 超长体头尾 | 前 1400 字符 == 原文前 1400;**末 600 字符 == 原文末 600**;中段标记内 N == `len(原文) - 2000` |
| **尾部关键字段可见**(设计 §7 用例 3c) | 以 issue 真实样本尾部 `"code":"invalid_parameter_error"}}` 收尾构造超长体 → 断言该串出现在摘要中 |
| 幂等 | `summarize_body(summarize_body(x)) == summarize_body(x)`(标记不嵌套) |
| `compose_message` | 摘要为空时返回原 message 不变;非空时以竖线分隔符拼接 |
| `response_body` 降级 | `httpx.Response(400, stream=<未读 SyncByteStream>)` → 返回 `""` 且不抛(构造法见下) |
未读响应的构造(已实测可用):
```python
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
resp = httpx.Response(400, stream=_Unread()) # 未 read → .text 抛 ResponseNotRead
```
**验收**: 摘要总长恒 ≤ `2000 + len(标记)`;头尾各自与原文逐字对应。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_http_error_body.py -v` → 全 PASS。
### - [ ] Task 3: openai_compat 翻译层收口
**改**: `src/polygateway/transports/openai_compat.py`
- 新增 `_classify`,`_status_to_error` 按上文骨架重写(五分支各拼各的 message → 查表 + 单点拼装)。
- `_translate_429` 签名改为 `(source, body_text, headers, ctx)`,两支 message 各自追加 `compose_message` 后缀,构造改用 `**ctx`;**`json.loads` 仍读原文 `body_text`**。
- message 主体逐字保持 1.1.2 原样(`凭据失效/欠费: {status}` / `请求被拒: 400` / `瞬时错误: {status}` / `客户端错误: {status}` / `配额耗尽(insufficient_quota)` / `限速: 429`),只在末尾追加 ` | {摘要}`
- 三个调用点(`:402` embed、`:417` stream、`:509` 非流式)签名不变,**不改动**。
- **补 import**:`PolyGatewayError`(errors)与 `compose_message` / `summarize_body`(`._http_errors`),见上文关键接口——漏补则 `make lint` 报 F821(Codex 审查 2026-08-16 提出)。
- **不改** `operation` 硬编码 `"chat"`(设计 §5.4 有意留给独立 issue)。
**测试**(`tests/unit/test_openai_compat.py`,沿用既有 `_transport_for(handler)` + `httpx.MockTransport`):
| # | 用例 | 断言 |
|---|---|---|
| 3.1 | 状态码参数化 400 / 401 / 403 / 404 / 500 / 503,handler 返回带真实样本体 | 异常类型与 1.1.2 **逐条相同**;message 含摘要;`exc.body_text` == 摘要 |
| 3.2 | 429 普通限速(body 无 `insufficient_quota`) | `TransientError`,message 含摘要,`retry_after_s` 解析不受影响 |
| 3.3 | 429 + `insufficient_quota` | `SourceDeadError`,message 含摘要 |
| 3.4 | **回归红线**:429 + `insufficient_quota` 且 body 长度 > 2048(前置大量填充字段) | 仍判 `SourceDeadError`——证明类型判定读的是原文而非摘要 |
| 3.5 | 空 body 的 400 | message 无悬空分隔符,`body_text == ""` |
| 3.6 | 非 JSON body、非 UTF-8 字节 body | 不抛额外异常,分类不变 |
| 3.7 | 流式路径(handler 对 stream 请求返回 400 + body) | 经 `_complete_stream:415-417` 抛出的异常同样带摘要 |
| 3.8 | embedding 路径(`transport.embed(...)` 遇 400) | 同样带摘要 |
**验收**: 既有测试零修改通过(除 3.x 新增外);`test_openai_compat.py:558`(match 源名)仍 PASS。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → 全 PASS。
### - [ ] Task 4: monkey_ocr 同款收口
**改**: `src/polygateway/transports/monkey_ocr.py`
- `_classify_status`:`summary = summarize_body(response_body(exc.response))`,三支 message 统一经 `compose_message` 追加后缀,`ctx``body_text=summary`
- message 主体保持 `f"{source_name} OCR {operation} HTTP {status}"` 不变。
- 429/5xx → `TransientError`、401/403 → `SourceDeadError`、其余 → `RequestRejectedError` 的映射**逐条不变**(OCR 无 429 细分是设计有意保留,见模块 docstring `:53-54`)。
**测试**(`tests/unit/test_monkey_ocr.py`):
- 扩展 `:300` 的状态码参数化:各分支 message 含摘要且 `body_text` 非空,分类不变。
- `ResponseNotRead` 降级:`exc.response` 为未读流 → `body_text == ""`,message 无悬空分隔符,**分类仍正确**(不得因取 body 失败而改变错误类型或抛出 httpx 异常)。
**验收**: `:195``:300` 既有断言不被改写。
**验证**: `conda run -n PolyGateway pytest tests/unit/test_monkey_ocr.py -v` → 全 PASS。
### - [ ] Task 5: 端到端遥测验收(**本计划的硬判据**)
**改**: `tests/integration/test_governance_stack.py`
新增用例,沿用既有 `_full_client(handler, telemetry=SQLiteRecorder(...))``:135``SELECT error FROM llm_calls` 断言模式:
- handler 对 chat 请求返回 `httpx.Response(400, content=<issue #10 真实样本体>)`
- `client.chat(...)``RequestRejectedError`(400 不重试不换源,行为不变)。
- `recorder.close()` 后查 `SELECT error FROM llm_calls`:该行 `error` 串**含样本体里的 `InvalidParameter` 与结尾的 `invalid_parameter_error`**。
真实样本体(取自 issue #10 原文,一字不改):
```json
{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal and cannot be opened","type":"invalid_request_error","param":"","code":"invalid_parameter_error"}}
```
**验收**: 这条断言在 Task 1-4 之前**必然失败**(1.1.2 的 `error` 列只有 `"qwen_1 请求被拒: 400"`),之后通过——这就是本 issue 的"先失败后通过"证据主体,执行时须保留失败输出截图/文本进提交说明。
**验证**: `conda run -n PolyGateway pytest tests/integration/test_governance_stack.py -v` → 全 PASS。
### - [ ] Task 6: 文档与版本
**改**:
| 文件 | 内容 |
|---|---|
| `src/polygateway/errors.py` | `RequestRejectedError` docstring 加一句:经中转部署时 400 可能源于中转自身抖动,批处理场景下游宜自备兜底分类(设计 §5.2) |
| `research-wiki/ARCHITECTURE.md` §6.2 | 同一提醒 + 注明四分类错误自 1.2.0 起携带 `body_text` |
| `CHANGELOG.md` | 新增 `## 1.2.0(2026-08-16)` 段:行为变更(message 追加摘要 → 遥测 `error` 列变长)、新增字段、不变项(分类映射零变更、错误面零变更) |
| `README.md:34` | 安装 pin `==1.1.*`**`>=1.2,<2`**(2026-08-16 人类定夺;漏改则下游静默停在 1.1.2) |
| `pyproject.toml` + `src/polygateway/__init__.py` | 版本号 `1.1.2``1.2.0`,**两处必须一致** |
**验收**: `grep -rn "1\.1\.\*" README.md` 零命中;两处版本号一致。
**验证**: `conda run -n PolyGateway python -c "import polygateway; print(polygateway.__version__)"``1.2.0`
### - [ ] Task 7: 合并前全量门
1. `make lint`(ruff + import-linter)→ 零违规,**重点确认新建 `transports/_http_errors.py` 未触发洋葱分层契约**。
2. `make test` 全套件 → 全 PASS,覆盖率不低于既有水平。
3. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3 Phase 2 硬门):逐条核对 Task 1-6 验收项与本会话工具输出。
4. `finishing-a-development-branch` 合并回 main(`--no-ff`),合并后在 main 上重跑 `make lint` 与全套件。
**发布**(合并后)严格按 CLAUDE.md §4.4.1 九步执行,不在本计划展开;其中步骤 1(更新 README)已在 Task 6 前置完成,**构建前须再次确认 pin 已是 `>=1.2,<2`**。
## 执行顺序与提交点
```
Task 1 ──┐
├── Task 3 ──┐
Task 2 ──┴── Task 4 ──┴── Task 5 ── Task 6 ── Task 7
```
Task 1 与 2 可并行(互不依赖);Task 3、4 都依赖 1+2;Task 5 依赖 3;Task 6 独立于代码但须在 Task 7 之前。每个 Task 一次语义化提交(`commit` skill),Task 5 的提交说明须附"修复前失败、修复后通过"的实际输出。
@@ -0,0 +1,269 @@
# 实现计划: 调用方自定义维度(issue #11)
- **目标**: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——`tenant_id` 为真实列(可挂 RLS),其余进 `meta` JSON 容器。
- **方案概述**: `llm_calls` 增两列(`tenant_id TEXT NOT NULL DEFAULT ''``meta` JSON);三个公共入口(`chat`/`embed`/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 `ValueError`;`TelemetryRecorder` 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
- **依据设计**: `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md`(已人类审批 2026-08-17)。
- **涉及技术**: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**
## 范围: 三条遥测链路(2026-08-17 人类追认)
设计初稿只覆盖 `chat()``embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit``ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。
**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/types.py` | 修改 | 新增 `validate_caller_dimensions()`;`ChatRequest` 增两字段 |
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 22 → 24 字段 |
| `src/polygateway/telemetry/sqlite.py` | 修改 | DDL 增两列、`_BACKFILL_COLUMNS` 增两项、`_COLUMNS` 增两项 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上(`_DDL`/`_BACKFILL`/`_COLUMNS`) |
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 归一化并透传;三个 emit 入口从 request 读取 |
| `src/polygateway/client.py` | 修改 | `chat()` 增两参数并校验 |
| `src/polygateway/embedding.py` | 修改 | `embed()` 增两参数;沿 `_embed_batch`/`_attempt`/`_emit` 透传 |
| `src/polygateway/ocr.py` | 修改 | `recognize_text`/`parse_layout` 增两参数;沿 `_call`/`_attempt`/`_emit` 透传 |
| `tests/unit/test_types.py` | 修改 | 校验函数红线 |
| `tests/unit/test_telemetry.py` | 修改 | 三个 emit 入口带维度 |
| `tests/unit/test_client.py``test_embedding.py``test_ocr_client.py` | 修改 | 三条链路各自的端到端透传 |
| `tests/unit/test_ports.py` | 修改 | 端口 24 字段契约 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG 补列与写入 |
| `README.md``CHANGELOG.md`、Gitea wiki | 修改 | 能力表、RLS 模板、版本说明 |
**依赖顺序**: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。
---
## 关键接口(跨任务消费,此处定稿)
校验函数(`types.py`,紧邻 `validate_request_overlay` 放置,同款风格):
```python
def validate_caller_dimensions(
tenant_id: str | None,
meta: Mapping[str, Any] | None,
*,
origin: str,
) -> tuple[str | None, dict[str, Any]]:
"""校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""
```
`ChatRequest` 新字段(必须带默认值,ARCH §5.1 约定①):
```python
tenant_id: str | None = None
meta: Mapping[str, Any] = field(default_factory=dict)
```
`TelemetryRecorder.record_llm_call` 新增两个**无默认值**参数(`ports.py` 现有纪律),排在 `reasoning_tokens` 之后:
```python
tenant_id: str, # 已归一化: None → ''
meta: str, # 已序列化: 空 dict → '{}'
```
**归一化在 emitter 完成,不在 recorder**——与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。
---
## Task 1: 校验函数与请求字段
**文件**: `src/polygateway/types.py`(修改)、`tests/unit/test_types.py`(修改)
**行为**:
`validate_request_overlay` 之后新增 `validate_caller_dimensions()`,按 Phase 组织(照搬既有风格):
- Phase 1 `tenant_id`: `None` 直接放行;非 `str` 报错;**`tenant_id != tenant_id.strip()` 报错**(首尾空白一律拒绝,不是"strip 后为空才拒绝"——`" t1"``"t1"` 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);`strip()` 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
- Phase 2 `meta` 键形态: 非 `str` 报错;不匹配 `^[a-z0-9_.]{1,64}$` 报错;以 `pg_` 开头报错(保留前缀)。
- Phase 3 `meta` 键数量: > 16 报错。
- Phase 4 `meta` 值: 类型不属 `(str, int, float, bool)` 报错(注意 `bool``int` 子类,先判 `bool` 无妨,两者都合法);`float``not math.isfinite(v)` 报错;`str` 且长度 > 256 报错。
- 返回 `(tenant_id, dict(meta or {}))` —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 `overlay` 先例)。
`ChatRequest` 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;`meta` 不进缓存 key(`cache_namespace` 已负责租户隔离,ARCH §7.5)。
**`ChatRequest.meta` 必须保存校验函数返回的那个浅拷贝**,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 `overlay` 拷贝语义的理由)。
**验收标准**: 每条红线抛 `ValueError` 且消息含 `origin`;合法输入返回浅拷贝且与入参不是同一对象。
**测试要求**(先失败后通过):
逐条红线各一个用例——`tenant_id` 空串/纯空白/**`" t1"`/`"t1 "`(首尾空白)**/超长/非 str;`meta` 键非 str/含大写/含连字符/超 64 字符/`pg_` 前缀/17 个键;值为 `list`/`dict`/`None`/`nan`/`inf`/`-inf`/超 256 字符的 str。另加合法路径用例: `tenant_id=None` + `meta={}` 放行、`meta` 值为 `bool`/`int`/`float` 有限值放行、返回值是拷贝(改返回值不影响入参)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
**另需一条缓存隔离测试**(放 `tests/unit/test_cache.py``test_client.py`): 相同 `messages` + 相同 `cache_namespace`、**仅 `meta` 不同**的两次调用,第二次**仍应命中缓存**。设计明确 `meta` 不进缓存 key(`cache_namespace` 已负责租户隔离);没有这条测试,实现者顺手把 `meta` 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
- [ ] 提交点: `feat: validate the dimensions a caller may attach to a call`
---
## Task 2: 端口与两个遥测后端 schema
**文件**: `src/polygateway/ports.py``src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py``tests/unit/test_ports.py`(均修改)
**行为**:
`ports.py`: `record_llm_call``tenant_id: str``meta: str`(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
`sqlite.py` 三处同步改(顺序必须一致):
- `_DDL``reasoning_tokens` 之后追加 `tenant_id TEXT NOT NULL DEFAULT ''``meta TEXT NOT NULL DEFAULT '{}'`;
- `_BACKFILL_COLUMNS` 追加 `("tenant_id", "TEXT NOT NULL DEFAULT ''")``("meta", "TEXT NOT NULL DEFAULT '{}'")` —— SQLite 硬性要求 `NOT NULL` 列必须带非 NULL 常量默认值,缺默认值会报 `Cannot add a NOT NULL column with default value NULL`;
- `_COLUMNS` 追加两项。
`postgres.py` 同三处:
- `_DDL` 追加 `tenant_id TEXT NOT NULL DEFAULT ''``meta JSONB NOT NULL DEFAULT '{}'::jsonb`;
- `_BACKFILL` 追加两条 `ALTER TABLE llm_calls ADD COLUMN ...`(默认值均为非易失常量,PG 11+ 不重写全表);
- `_COLUMNS` 追加两项。
**新列必须排在末尾**(`created_at` 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(`sqlite.py:53` 既有注释)。
**验收标准**: 两个后端的 `_COLUMNS` 逐字同名同序;新建库与旧表补列后列集合一致。
**测试要求**(先失败后通过): 扩展现有列序断言测试,断言两后端 `_COLUMNS` 相等且末两项为 `("tenant_id", "meta")`;`test_ports.py` 断言 `record_llm_call` 的参数集合含新两项且**无默认值**(用 `inspect.signature` 实测,不凭记忆)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS
- [ ] 提交点: `feat: give the telemetry table a tenant column and a meta container`
---
## Task 3: Emitter 透传与归一化
**文件**: `src/polygateway/middleware/telemetry.py`(修改)、`tests/unit/test_telemetry.py`(修改)
**行为**:
`_record` 增两个形参,**位置排在现有末参 `reasoning_tokens` 之后**(它是 keyword-only,顺序不影响调用,但与 `_COLUMNS`/端口的追加位置保持一致便于逐行比对):
```python
async def _record(
self, *, ...,
reasoning_tokens: int | None,
tenant_id: str | None, # 新增: 未归一化,None 合法
meta: Mapping[str, Any], # 新增: 未序列化,空 dict 合法
) -> None:
```
在传给 recorder 前归一化: `tenant_id or ''`;`json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)`,空 dict 直接用字面量 `'{}'`
`allow_nan=False` 是第二道闸(主防线是 Task 1 的入口校验)——`json.dumps` 默认把 `nan` 写成 `NaN` 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。
三个 emit 入口统一从 `request.tenant_id` / `request.meta` 读取,不各自组装(遥测调用点收敛铁律):
- `emit_attempt``emit_terminal_failure`: 直接读 `request`;
- `emit_cache_hit`: **同样读 `request` 而非缓存中的历史响应**——维度是"本次调用由谁发起",不是历史那次。
**验收标准**: 三条路径写出的行都带维度;`tenant_id=None``''`;`meta={}``'{}'` 而非 NULL。
**测试要求**(先失败后通过): 用 fake recorder 断言三个入口各自收到的 `tenant_id`/`meta` 值;`emit_cache_hit` 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 **A**(这是最容易实现反的一处);`meta` 序列化后键有序(`sort_keys=True`,便于跨行比对)。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions through the single telemetry helper`
---
## Task 4: `chat()` 公共入口
**文件**: `src/polygateway/client.py`(修改)、`tests/unit/test_client.py`(修改)
**行为**: `chat()` 签名末尾增 `tenant_id: str | None = None``meta: Mapping[str, Any] | None = None`(带默认值的 keyword-only,签名冻结承诺不破)。在既有 `validate_request_overlay` 调用旁调 `validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)")`,把返回值填进 `ChatRequest`。校验必须在**进洋葱之前**——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。
**验收标准**: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 `chat()``ValueError` 且**未产生任何遥测行**。
**测试要求**(先失败后通过): 端到端——`chat(..., tenant_id="t1", meta={"batch": "b-42"})` 后 fake recorder 收到的行带这两个值;非法 `meta``ValueError` 且 recorder **零调用**(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 `''``'{}'`
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
- [ ] 提交点: `feat: let chat() take a tenant and caller-defined dimensions`
---
## Task 5: `embed()` 链路透传
**文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
**行为**: `embed()` 增两个 keyword-only 参数并在入口校验(`origin="embed(tenant_id=..., meta=...)"`),沿 `_embed_batch()``_attempt()``_emit()` **逐层透传**,在 `_emit()`(`embedding.py:360`)构造 `ChatRequest` 时填入。
该链路已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数。**不顺手把它们收成值对象**——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。
**验收标准**: 多批(`texts` 长度 > `batch_size`)时**每一批的行都带同一份维度**——维度属于本次 `embed()` 调用,不随批次变化。
**测试要求**(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的**每一行**都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 `ValueError` 且零遥测。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_embedding.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions down the embedding chain`
---
## Task 6: OCR 链路透传
**文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
**行为**: `recognize_text()``parse_layout()` 各增两个 keyword-only 参数并在入口校验(`origin` 分别标明方法名),沿 `_call()``_attempt()``_emit()` 透传,在 `_emit()`(`ocr.py:398`)构造 `ChatRequest` 时填入。结构与 Task 5 同构。
**验收标准**: 两个公共方法都覆盖(只改一个即漏)。
**测试要求**(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 `ValueError` 且零遥测。
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v` → PASS
- [ ] 提交点: `feat: carry caller dimensions through the OCR chain`
---
## Task 7: 真实后端集成验收
**文件**: `tests/integration/test_postgres_telemetry.py`(修改)、`tests/unit/test_telemetry.py`(补 SQLite 真实文件用例)
**行为**: 覆盖三件事,每件两个后端各测一遍。
1. **新建库**: 表列齐全,写入后读回维度一致。
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
3. **补列失败的降级方向**: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。**两端的失败构造方式不同,不可笼统写"各测一遍"**:
- **Postgres**: 用只有 `SELECT, INSERT ON llm_calls` 权限的角色连接——`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 的存在性判断,故必然失败。断言: 记 warning、`_failed` **未**置位、后续 INSERT 仍尝试。
- **SQLite**: 无角色权限模型,等价构造是**文件只读**(`chmod 444` 或以 `file:...?mode=ro` 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 `self._conn`、不抛出 `__init__`"(即 `sqlite.py:112` 那条既有纪律),**不断言"写入仍成功"**——那在只读库上本就不可能。
**验收标准**: PG 与 SQLite 行为对称;补列走既有 `_BACKFILL`,不新增 DDL 路径。
**测试要求**(先失败后通过): 上述三项即测试本体,**同样适用红绿证据门**——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。
**验证命令**:
`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS
`conda run -n PolyGateway pytest tests/ -q` → 全套件 PASS(**命令末尾不接管道**,否则退出码失真)
- [ ] 提交点: `test: prove old telemetry tables gain the tenant column safely`
---
## Task 8: 文档同步
**文件**: `README.md``CHANGELOG.md`、Gitea wiki 的 `指南-遥测与成本``参考-公共API` 两页
`docs-convention.md` §2「新公共 API / 新能力」行,须同步「对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG」。本次**扩写** `指南-遥测与成本`(新增"多租户与自定义维度"一节)而非新建页,故**侧边栏与 `Home.md` 不动**——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 `指南-多租户`),则必须一并改 `_Sidebar.md``Home.md` 分流表。
**行为**:
- **CHANGELOG**: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、**以及库不建索引/不启用 RLS 的边界**。
- **README**: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 `inspect.signature` **实测**后再写(发布流程 §4.4.1 第 1 步的教训)。
- **`参考-公共API`**: 更新 `chat()``embed()`(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
- **`指南-遥测与成本`**: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(`ENABLE` + `FORCE` + `USING`/`WITH CHECK` 双写 + `NULLIF(current_setting(..., true), '')`)与复合索引 `(tenant_id, created_at)`,并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在**显式事务内**用 `set_config(..., true)`(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行。
- wiki 必须明确: **执行这些 DDL 是下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个普通列,没有数据库层强制。
**验收标准**: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。
**验证命令**: `conda run -n PolyGateway make lint` → PASS;人工核对 wiki 页面渲染。
- [ ] 提交点: `docs: document caller dimensions and the RLS template`
---
## 全局验收
- [ ] `conda run -n PolyGateway make lint` → PASS(含 import-linter 依赖契约)
- [ ] `conda run -n PolyGateway make test` → PASS,覆盖率不低于改动前
- [ ] 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
- [ ] 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
- [ ] 旧表补列后老行读出空串的证据(issue 核心论点的验收)
@@ -0,0 +1,37 @@
---
type: plan
node_id: plan:issue10-error-body-retention-plan
title: "实现计划: HTTP 错误响应体留存(Issue #10)"
date: 2026-08-16
---
# 实现计划: HTTP 错误响应体留存(Issue #10)
**全文**: `plans/2026-08-16-issue10-error-body-retention.md`|**实现**: [[design:issue10-error-body-retention]]|**分支**: `feat/issue-10-error-body-retention`
## 任务分解
| # | 任务 | 产出 |
|---|---|---|
| 1 | 内核字段 | `PolyGatewayError.body_text`(默认空串)+ 与 `raw_text` 的界限 docstring |
| 2 | 共享摘要单元 | 新建 `transports/_http_errors.py`:`summarize_body` / `compose_message` / `response_body` |
| 3 | openai_compat 收口 | `_status_to_error` 表驱动;429 判类型仍读原文 |
| 4 | monkey_ocr 收口 | `_classify_status` 同款,含 `ResponseNotRead` 降级 |
| 5 | **端到端验收** | 400 调用后 SQLite `error` 列含摘要——本计划的硬判据 |
| 6 | 文档与版本 | 1.2.0、README pin `>=1.2,<2`、CHANGELOG、ARCHITECTURE §6.2 |
| 7 | 合并前门 | lint + 全套件 + 全新上下文 verifier |
依赖:1‖2 → 3‖4 → 5 → 6 → 7。
## 计划期钉死的两条实现红线
1. **429 判 `insufficient_quota` 必须解析未截断原文**,不得改用摘要——摘要会破坏 JSON,超长体一旦改用摘要解析,配额耗尽的源将不再 `force_open`,把诊断改进变成治理 bug。Task 3.4 有专门回归用例。
2. **message 主体逐字保持 1.1.2 原样**,只在末尾追加摘要后缀;状态码→分类映射逐条不变,验收要求既有分类断言零改写。
## 保真校验
不涉及 `reference/` 迁移。错误分类映射不变,保真体现为"既有分类断言全部保留"。
## 执行期观察
Task 计划提交时 pre-commit 钩子的全套件跑出现一次 `tests/e2e/test_compat_projects.py::TestGovDocOnboarding::test_call_site_shape_runs_governed` 失败,单独跑与 e2e 全目录跑(7 passed / 23.30s,每例 2-3.5s)均通过,重跑全套件亦通过 → 判为真实网关抖动,非回归。该用例正是 [[design:issue8-stall-budget]] 当年记录的三个漂移用例之一,e2e 打真实网关的固有 flaky 面仍在。
@@ -0,0 +1,17 @@
---
type: plan
node_id: plan:issue11-caller-dimensions
title: "调用方自定义维度实现计划(issue #11)"
date: 2026-08-17
---
# 调用方自定义维度实现计划(issue #11)
正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`
- **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。
- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit``ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。
- **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。
- **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。
- **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。
+1 -1
View File
@@ -32,7 +32,7 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
__version__ = "1.1.1" __version__ = "1.2.1"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
+20 -1
View File
@@ -34,7 +34,12 @@ from polygateway.sources import (
SourceCooldownMemo, SourceCooldownMemo,
) )
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay from polygateway.types import (
ChatRequest,
LLMResponse,
validate_caller_dimensions,
validate_request_overlay,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Iterable, Mapping from collections.abc import Awaitable, Iterable, Mapping
@@ -205,12 +210,18 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None, structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True, stream: bool = True,
overlay: Mapping[str, Any] | None = None, overlay: Mapping[str, Any] | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> LLMResponse: ) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。 """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级 `overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
参数不影响既有调用点(issue #4)。 参数不影响既有调用点(issue #4)。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
""" """
if structured is not None and not self._structured_available: if structured is not None and not self._structured_available:
raise ImportError( raise ImportError(
@@ -221,6 +232,12 @@ class GatewayClient:
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入, # 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E) # 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)") sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
)
request = ChatRequest( request = ChatRequest(
messages=messages, messages=messages,
session_id=session_id, session_id=session_id,
@@ -231,6 +248,8 @@ class GatewayClient:
stream=stream, stream=stream,
overlay=sampling, overlay=sampling,
sampling=sampling, sampling=sampling,
tenant_id=dimension_tenant_id,
meta=dimensions,
) )
return await self._handler(request) return await self._handler(request)
+79 -9
View File
@@ -21,7 +21,7 @@ import random
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
@@ -47,6 +47,7 @@ from polygateway.types import (
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -145,10 +146,22 @@ class EmbeddingClient:
*, *,
session_id: str | None = None, session_id: str | None = None,
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> EmbeddingResponse: ) -> EmbeddingResponse:
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。""" """一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
本次调用而非某一批,故每批的遥测行都带同一份维度。
"""
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts): if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)") raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
if not texts: if not texts:
return EmbeddingResponse( return EmbeddingResponse(
vectors=[], vectors=[],
@@ -167,7 +180,11 @@ class EmbeddingClient:
for start in range(0, len(texts), self._batch_size): for start in range(0, len(texts), self._batch_size):
outcomes.append( outcomes.append(
await self._embed_batch( await self._embed_batch(
texts[start : start + self._batch_size], session_id, parent_call_id texts[start : start + self._batch_size],
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
) )
) )
return self._merge(outcomes) return self._merge(outcomes)
@@ -175,7 +192,12 @@ class EmbeddingClient:
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)—— # —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
async def _embed_batch( async def _embed_batch(
self, batch: list[str], session_id: str | None, parent_call_id: str | None self,
batch: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome: ) -> _BatchOutcome:
fails = 0 fails = 0
reasons: dict[str, str] = {} reasons: dict[str, str] = {}
@@ -187,7 +209,9 @@ class EmbeddingClient:
await self._on_no_runnable(gate_rejections, reasons, clock) await self._on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id) outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _BatchOutcome): if isinstance(outcome, _BatchOutcome):
return outcome return outcome
fails += 1 fails += 1
@@ -266,6 +290,8 @@ class EmbeddingClient:
reasons: dict[str, str], reasons: dict[str, str],
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome | _FailedBatch: ) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
@@ -286,17 +312,45 @@ class EmbeddingClient:
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result) await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _BatchOutcome(result, source, call_id, latency_ms) return _BatchOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc: except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry) await self._gate_on_terminal(exc, entry)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc) await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise raise
except asyncio.CancelledError: except asyncio.CancelledError:
if entry.is_probe: if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry)) await self._record_quietly(self._breaker.release_probe(entry))
await self._emit( await self._emit(
batch, source, call_id, started, session_id, parent_call_id, error="cancelled" batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
) )
raise raise
except (SourceDeadError, TransientError) as exc: except (SourceDeadError, TransientError) as exc:
@@ -307,7 +361,17 @@ class EmbeddingClient:
if not dead: if not dead:
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值 # 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens() actual = source.effective_est_tokens()
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc) await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedBatch(exc, immediate=dead) return _FailedBatch(exc, immediate=dead)
finally: finally:
await self._settle_and_release(permit, actual) await self._settle_and_release(permit, actual)
@@ -351,16 +415,22 @@ class EmbeddingClient:
started: float, started: float,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: EmbeddingTransportResult | None = None, result: EmbeddingTransportResult | None = None,
error: object | None = None, error: object | None = None,
) -> None: ) -> None:
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。""" """逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
if self._emitter is None: if self._emitter is None:
return return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
request = ChatRequest( request = ChatRequest(
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch], messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
session_id=session_id, session_id=session_id,
parent_call_id=parent_call_id, parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
) )
response = None response = None
if result is not None: if result is not None:
+32 -2
View File
@@ -35,7 +35,26 @@ SOURCE_REASONS = frozenset(
class PolyGatewayError(Exception): class PolyGatewayError(Exception):
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。""" """库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
============== ==================================================
``body_text`` **非 2xx** 的 HTTP 错误响应体: 对方**拒绝**的理由
``raw_text`` **2xx** 但内容不可解析时的模型输出原文
============== ==================================================
加在基类而非某个子类,是因为这些错误全部由同一个 HTTP 响应翻译而来——
"对方说了什么""它属于哪一类"正交。scope 级错误(`GatewayUnavailableError`
一族)继承到的恒空值不是噪音,而是"没有单一响应体可言"的如实表达。
**内容已由 transport 层截断**(`transports/_http_errors.summarize_body`),
且可能包含网关对请求的回显——库不做脱敏: 它不知道下游哪些字段敏感,
猜测式脱敏只会同时丢掉诊断价值与安全性。
本字段是**旁路数据**,不参与任何治理判定(重试/换源/熔断计数/限流结算)。
"""
def __init__( def __init__(
self, self,
@@ -44,11 +63,13 @@ class PolyGatewayError(Exception):
source_name: str | None = None, source_name: str | None = None,
status_code: int | None = None, status_code: int | None = None,
operation: str | None = None, operation: str | None = None,
body_text: str = "",
) -> None: ) -> None:
super().__init__(message) super().__init__(message)
self.source_name = source_name self.source_name = source_name
self.status_code = status_code self.status_code = status_code
self.operation = operation self.operation = operation
self.body_text = body_text
class TransientError(PolyGatewayError): class TransientError(PolyGatewayError):
@@ -64,7 +85,16 @@ class SourceDeadError(PolyGatewayError):
class RequestRejectedError(PolyGatewayError): class RequestRejectedError(PolyGatewayError):
"""请求被拒(400/坏输入): 不重试不换源,直接上抛。""" """请求被拒(400/坏输入): 不重试不换源,直接上抛。
**经中转部署时请注意**(issue #10 下游实测): 第三方 API 中转服务自身抖动
时也会回 400,从状态码上与供应商说"你的输入有问题"无法区分。下游曾观测到
同一份字节(sha256 一致)重发 15 次全部成功,且失败那次 `prompt_tokens=0`、
耗时远低于任何成功调用——请求在推理开始前就被挡了。本库仍按确定性失败处理
(对直连供应商而言重试只会白烧配额),批处理场景的下游宜自备兜底分类;
`body_text` 即为此提供判据: 中转抖动的响应体与供应商的 `invalid_request_error`
形态不同。
"""
class ResultInvalidError(PolyGatewayError): class ResultInvalidError(PolyGatewayError):
+41 -2
View File
@@ -26,13 +26,35 @@ from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling from polygateway.types import canonical_sampling_json, merge_sampling
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable, Mapping
from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.pricing import PricingTable from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"
"""
if not meta:
return "{}"
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
@dataclass(frozen=True) @dataclass(frozen=True)
class _AttemptUsage: class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。 """一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
@@ -72,7 +94,7 @@ class _AttemptUsage:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None: def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
self._recorder = recorder self._recorder = recorder
@@ -111,6 +133,8 @@ class TelemetryEmitter:
reasoning_tokens=usage.reasoning_tokens, reasoning_tokens=usage.reasoning_tokens,
# 唯一有"生效源"的入口,故是唯一能并上 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,
meta=request.meta,
) )
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None: async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
@@ -139,6 +163,11 @@ class TelemetryEmitter:
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损: # 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同 # sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling), sampling=canonical_sampling_json(request.sampling),
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id,
meta=request.meta,
) )
async def emit_terminal_failure( async def emit_terminal_failure(
@@ -166,6 +195,9 @@ class TelemetryEmitter:
reasoning_tokens=None, reasoning_tokens=None,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D) # 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling), sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id,
meta=request.meta,
) )
async def _record( async def _record(
@@ -190,6 +222,9 @@ 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 #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -231,6 +266,10 @@ class TelemetryEmitter:
model_reported=model_reported, model_reported=model_reported,
sampling=sampling, sampling=sampling,
reasoning_tokens=reasoning_tokens, reasoning_tokens=reasoning_tokens,
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+82 -10
View File
@@ -18,7 +18,7 @@ import random
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Any, Literal
from loguru import logger from loguru import logger
@@ -46,6 +46,7 @@ from polygateway.types import (
OcrTextResult, OcrTextResult,
Usage, Usage,
strip_unsupported_extra_body, strip_unsupported_extra_body,
validate_caller_dimensions,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -142,9 +143,21 @@ class OcrClient:
*, *,
session_id: str | None = None, session_id: str | None = None,
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrTextResult: ) -> OcrTextResult:
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"""" """一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"
outcome = await self._call("text", image, session_id, parent_call_id)
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result result = outcome.result
return OcrTextResult( return OcrTextResult(
text=result.text, text=result.text,
@@ -161,9 +174,20 @@ class OcrClient:
*, *,
session_id: str | None = None, session_id: str | None = None,
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrLayoutResult: ) -> OcrLayoutResult:
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"""" """一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"
outcome = await self._call("layout", image, session_id, parent_call_id)
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result result = outcome.result
return OcrLayoutResult( return OcrLayoutResult(
elements=result.elements, elements=result.elements,
@@ -195,6 +219,8 @@ class OcrClient:
image: bytes, image: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome: ) -> _AttemptOutcome:
if not isinstance(image, bytes): if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)") raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
@@ -213,7 +239,7 @@ class OcrClient:
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
) )
if isinstance(outcome, _AttemptOutcome): if isinstance(outcome, _AttemptOutcome):
return outcome return outcome
@@ -294,9 +320,13 @@ class OcrClient:
reasons: dict[str, str], reasons: dict[str, str],
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome | _FailedAttempt: ) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
try: try:
result = await self._invoke(kind, image, source, call_id) result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
@@ -304,20 +334,47 @@ class OcrClient:
self._feed_outcome(source.name, ok=True) self._feed_outcome(source.name, ok=True)
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
await self._emit( await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, result kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
) )
return _AttemptOutcome(result, source, call_id, latency_ms) return _AttemptOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc: except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry) await self._gate_on_terminal(exc, entry)
await self._emit( await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
) )
raise raise
except asyncio.CancelledError: except asyncio.CancelledError:
if entry.is_probe: if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry)) await self._record_quietly(self._breaker.release_probe(entry))
await self._emit( await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled" kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
) )
raise raise
except (SourceDeadError, TransientError) as exc: except (SourceDeadError, TransientError) as exc:
@@ -327,7 +384,16 @@ class OcrClient:
await self._record_quietly(self._breaker.record_failure(entry, reason, dead)) await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
self._feed_outcome(source.name, ok=False) self._feed_outcome(source.name, ok=False)
await self._emit( await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
) )
return _FailedAttempt(exc, immediate=dead) return _FailedAttempt(exc, immediate=dead)
finally: finally:
@@ -389,16 +455,22 @@ class OcrClient:
started: float, started: float,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None, result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None, error: object | None = None,
) -> None: ) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。""" """逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None: if self._emitter is None:
return return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
request = ChatRequest( request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}], messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id, session_id=session_id,
parent_call_id=parent_call_id, parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
) )
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
response = None response = None
+8 -1
View File
@@ -245,10 +245,15 @@ class StructuredOutputStrategy(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;20 字段冻结(M1 设计 §4.4 + issue #3),唯一调用点是 TelemetryEmitter。 """遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
""" """
async def record_llm_call( async def record_llm_call(
@@ -276,4 +281,6 @@ class TelemetryRecorder(Protocol):
model_reported: str | None, model_reported: str | None,
sampling: str | None, sampling: str | None,
reasoning_tokens: int | None, reasoning_tokens: int | None,
tenant_id: str,
meta: str,
) -> None: ... ) -> None: ...
+87 -15
View File
@@ -1,12 +1,17 @@
"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。 """Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。
参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc 参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc
`taskrun/postgres_store.py`($n 占位、`CREATE TABLE IF NOT EXISTS`、 `taskrun/postgres_store.py`($n 占位、`ON CONFLICT DO NOTHING`),但其
`ON CONFLICT DO NOTHING`),但其"失败冒泡"方向按遥测铁律**有意反转**: "失败冒泡"方向按遥测铁律**有意反转**:
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路); ① 结构性失败 → warning 一次后永久降级(所有写入短路);
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由 ② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。 asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
构造不连库(lazy),20 列 schema 与 SQLite 版同名同序。 构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
**"结构性"的判据是「确定写不进去」,不是「初始化时出过错」**(issue #9):
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)与"表确定不存在
且建不出来"(后续 INSERT 必然全败)才判死;探测失败、补列失败、取连接
失败一律只 warning,让写入照常尝试或下次调用重试。
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,7 +48,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cached_prompt_tokens INTEGER, cached_prompt_tokens INTEGER,
model_reported TEXT, model_reported TEXT,
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb
); );
""" """
@@ -53,8 +60,20 @@ _BACKFILL = (
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"), ("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"), ("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"), ("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
(
"tenant_id",
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''",
),
(
"meta",
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
),
) )
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析) # 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
_EXISTING_COLUMNS = ( _EXISTING_COLUMNS = (
"SELECT attname FROM pg_attribute " "SELECT attname FROM pg_attribute "
@@ -84,6 +103,8 @@ _COLUMNS = (
"model_reported", "model_reported",
"sampling", "sampling",
"reasoning_tokens", "reasoning_tokens",
"tenant_id",
"meta",
) )
_INSERT = ( _INSERT = (
@@ -111,30 +132,81 @@ class PostgresRecorder:
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
async def _ensure_ready(self) -> asyncpg.Pool | None: async def _ensure_ready(self) -> asyncpg.Pool | None:
"""lazy 建池+表;结构性失败 warning 一次后永久降级(设计 §5 两级之一)""" """lazy 建池+表;判死只认「确定写不进去」(issue #9),其余失败都留活路"""
if self._failed: if self._failed:
return None return None
if self._schema_ready: if self._schema_ready:
return self._pool return self._pool
async with self._init_lock: async with self._init_lock:
if self._failed or self._schema_ready: if self._failed:
return None if self._failed else self._pool return None
if self._schema_ready:
return self._pool
pool = await self._open_pool()
if pool is None:
return None
return await self._prepare_schema(pool)
async def _open_pool(self) -> asyncpg.Pool | None:
"""建池;失败即永久降级(唯一一处「无条件判死」)。"""
if self._pool is not None:
return self._pool
try: try:
if self._pool is None:
import asyncpg import asyncpg
self._pool = await asyncpg.create_pool(self._dsn, timeout=10) self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
async with self._pool.acquire() as conn:
await conn.execute(_DDL)
await self._backfill_columns(conn)
self._schema_ready = True
return self._pool
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
# 池建不出来 = 确定写不进去;且每次调用重试都要内联吞掉 connect
# 超时,而遥测是业务路径上的 await —— 此处必须永久降级
self._failed = True self._failed = True
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc) logger.warning("Postgres 遥测建池失败,后续记录降级为 no-op: {}", exc)
return None return None
return self._pool
async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None:
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
try:
async with pool.acquire() as conn:
writable = await self._prepare_table(conn)
except asyncio.CancelledError:
raise
except Exception as exc:
# 池已在手,取连接/探测失败多为瞬时抖动: 不判死也不标就绪,
# 只跳过本次记录,下次调用重新准备
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
return None
if not writable:
self._failed = True
return None
self._schema_ready = True
return pool
async def _prepare_table(self, conn: object) -> bool:
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
只授 `SELECT, INSERT ON llm_calls` 的角色,表明明在、也写得进去,这一句
照样被拒 `permission denied for schema`)。这与 `_backfill_columns` 撞的
是同一类问题(issue #3/#9),故守卫也必须同款: 先探测,后 DDL。
探测走 `to_regclass`,不需要任何权限,且与 INSERT 的 search_path 解析
口径一致——比裸 DDL 更准(裸 `CREATE TABLE` 落在首个**可建**的 schema,
可能与 INSERT 命中的不是同一张表)。
"""
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
if exists:
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
return True
try:
await conn.execute(_DDL) # type: ignore[attr-defined]
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
return False
return True # 新建表列已齐全,无需再走补列
async def _backfill_columns(self, conn: object) -> None: async def _backfill_columns(self, conn: object) -> None:
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。 """给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
+18 -2
View File
@@ -3,6 +3,14 @@
蓝本 VT `adapters/telemetry.py`: 构造期建连接与表,失败降级为 no-op 蓝本 VT `adapters/telemetry.py`: 构造期建连接与表,失败降级为 no-op
(记录基础设施不得拖垮业务调用);`INSERT OR IGNORE` 幂等(call_id 主键); (记录基础设施不得拖垮业务调用);`INSERT OR IGNORE` 幂等(call_id 主键);
写入经 threading.Lock 串行化后由 `asyncio.to_thread` 执行,不阻塞事件循环。 写入经 threading.Lock 串行化后由 `asyncio.to_thread` 执行,不阻塞事件循环。
**这里不做 postgres.py 那样的建表前探测,是实测后的有意不对称**(issue #9):
SQLite 对已存在的表在**解析期**就把 `CREATE TABLE IF NOT EXISTS` 短路掉,
既不抢写锁也不检查可写性——实测同一时刻另一连接持 `BEGIN EXCLUSIVE`、或
文件 `chmod 444`,该语句均通过,而同条件下的 `INSERT` 与新表名建表分别报
database is locked / readonly database。故 PG 侧"权限检查早于存在性判断"
的坑在此不存在,加探测零收益。**别为了代码对称把它加回来**;需要对称的是
保证(表存在就不该因建表失败而失能),这一条两侧都已满足。
""" """
from __future__ import annotations from __future__ import annotations
@@ -38,7 +46,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cached_prompt_tokens INTEGER, cached_prompt_tokens INTEGER,
model_reported TEXT, model_reported TEXT,
sampling TEXT, sampling TEXT,
reasoning_tokens INTEGER reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}'
); );
""" """
@@ -49,6 +59,10 @@ _BACKFILL_COLUMNS = (
("model_reported", "TEXT"), ("model_reported", "TEXT"),
("sampling", "TEXT"), ("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"), ("reasoning_tokens", "INTEGER"),
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
) )
_COLUMNS = ( _COLUMNS = (
@@ -74,6 +88,8 @@ _COLUMNS = (
"model_reported", "model_reported",
"sampling", "sampling",
"reasoning_tokens", "reasoning_tokens",
"tenant_id",
"meta",
) )
_INSERT = ( _INSERT = (
@@ -129,7 +145,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:
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。""" """写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。"""
if self._conn is None: if self._conn is None:
return return
row = tuple(fields[col] for col in _COLUMNS) row = tuple(fields[col] for col in _COLUMNS)
@@ -0,0 +1,63 @@
"""HTTP 错误响应体的取用与摘要(issue #10 设计 §3.2)。
两个 transport 各有自己的状态码分类逻辑(OCR 有意不做 429 细分),但**摘要口径
必须是同一份**——issue #10 的教训正是"只有一个分支用了响应体",一处例外就是
下一次事后查不到原因。故本模块是全库唯一的摘要实现,不得在别处复制。
"""
from __future__ import annotations
import httpx
_ERROR_BODY_CAP = 2048
"""摘要总长上限(**字符**,含省略标记在内)。
取值对齐 Kubernetes client-go `rest/request.go` 的 `maxUnstructuredResponseTextBytes
= 2048`——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。按字符而非
字节切,多字节字符不会被切成半个;`error` 列是 TEXT,无定长约束,不需要字节口径。
"""
_HEAD_CHARS = 1400
_TAIL_CHARS = 600
def summarize_body(text: str) -> str:
"""折叠空白后按头尾策略摘要;空/空白入参返回空串。
**折叠空白**不是洁癖: 错误体常是缩进 JSON,原样拼进 message 会把一行日志
炸成多行、把遥测列变得不可读。
**保头保尾**而非头部硬切: 截断的对象是结构化 JSON,信息分布头重尾也重——
人话(`message`)在前,机器可判的 `type`/`code`/`param`/`request_id` 在后。
k8s/Sentry 用头部硬切是因为它们截的是任意文本;本函数截的是错误 JSON,
头部硬切正好切掉向网关方追查时唯一有用的那部分。策略取自标准库 `reprlib`
"给人读的长字符串"的处置。
**标记记下省略字数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。
"""
collapsed = " ".join(text.split())
if len(collapsed) <= _ERROR_BODY_CAP:
return collapsed
omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS
return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}"
def compose_message(message: str, summary: str) -> str:
"""摘要非空才拼后缀,避免留下悬空的分隔符。
分隔符取 ` | ` 而非既有的 `: `,让"库说的话""网关说的话"一眼可分。
"""
return f"{message} | {summary}" if summary else message
def response_body(response: httpx.Response) -> str:
"""取**已缓冲**的响应文本;未读缓冲一律降级空串。
绝不在此触发网络读: 那会在错误路径上凭空插入一次可能挂住的 IO。降级方向
与缓存/遥测同档(库铁律)——诊断信息缺失不得把一次本可正确分类的失败变成
不可分类的崩溃,那正是 `ResponseNotRead` 泄漏出四分类之外的后果。
"""
try:
return response.text
except httpx.ResponseNotRead:
return ""
+13 -1
View File
@@ -24,6 +24,11 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.transports._http_errors import (
compose_message,
response_body,
summarize_body,
)
from polygateway.types import ( from polygateway.types import (
OcrLayoutElement, OcrLayoutElement,
OcrLayoutTransportResult, OcrLayoutTransportResult,
@@ -74,13 +79,20 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
def _classify_status( def _classify_status(
exc: httpx.HTTPStatusError, source_name: str, operation: str exc: httpx.HTTPStatusError, source_name: str, operation: str
) -> TransientError | SourceDeadError | RequestRejectedError: ) -> TransientError | SourceDeadError | RequestRejectedError:
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
"只有一个分支用了响应体"正是 issue #10 的成因。
"""
status = exc.response.status_code status = exc.response.status_code
summary = summarize_body(response_body(exc.response))
ctx: dict[str, Any] = { ctx: dict[str, Any] = {
"source_name": source_name, "source_name": source_name,
"status_code": status, "status_code": status,
"operation": operation, "operation": operation,
"body_text": summary,
} }
message = f"{source_name} OCR {operation} HTTP {status}" message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
if status >= 500 or status == 429: if status >= 500 or status == 429:
return TransientError(message, **ctx) return TransientError(message, **ctx)
if status in (401, 403): if status in (401, 403):
+39 -18
View File
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any
import httpx import httpx
from polygateway.errors import ( from polygateway.errors import (
PolyGatewayError,
RequestRejectedError, RequestRejectedError,
ResultInvalidError, ResultInvalidError,
SourceDeadError, SourceDeadError,
@@ -30,6 +31,7 @@ from polygateway.providers import (
resolve_thinking, resolve_thinking,
) )
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -107,40 +109,59 @@ def _parse_retry_after(raw: str | None) -> float | None:
return seconds if seconds > 0 else None return seconds if seconds > 0 else None
def _translate_429(source: SourceConfig, body_text: str, headers: Mapping[str, str]) -> Exception: def _translate_429(
source: SourceConfig, body_text: str, headers: Mapping[str, str], ctx: dict[str, Any]
) -> Exception:
"""429 细分。`body_text` 必须是**未截断的原文**——`ctx["body_text"]` 是摘要,
头尾保留会破坏 JSON 结构,拿它解析会让超长 body 的配额耗尽退化成普通限速
(该源不再 force_open),把一个诊断改进变成治理 bug(issue #10 实现红线)。
"""
try: try:
err_type = json.loads(body_text).get("error", {}).get("type", "") err_type = json.loads(body_text).get("error", {}).get("type", "")
except (json.JSONDecodeError, AttributeError): except (json.JSONDecodeError, AttributeError):
err_type = "" err_type = ""
summary = ctx["body_text"]
if err_type == "insufficient_quota": if err_type == "insufficient_quota":
return SourceDeadError( return SourceDeadError(
f"{source.name} 配额耗尽(insufficient_quota)", compose_message(f"{source.name} 配额耗尽(insufficient_quota)", summary), **ctx
source_name=source.name,
status_code=429,
operation="chat",
) )
return TransientError( return TransientError(
f"{source.name} 限速: 429", compose_message(f"{source.name} 限速: 429", summary),
retry_after_s=_parse_retry_after(headers.get("retry-after")), retry_after_s=_parse_retry_after(headers.get("retry-after")),
source_name=source.name, **ctx,
status_code=429,
operation="chat",
) )
def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
"""状态码 → (错误类, message 标签);映射与 ARCH §6.2 逐条相同,本次零变更。"""
if status in (401, 403):
return SourceDeadError, "凭据失效/欠费"
if status == 400:
return RequestRejectedError, "请求被拒"
if status >= 500:
return TransientError, "瞬时错误"
return RequestRejectedError, "客户端错误"
def _status_to_error( def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str] source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
) -> Exception: ) -> Exception:
ctx: dict[str, Any] = {"source_name": source.name, "status_code": status, "operation": "chat"} """非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
if status in (401, 403):
return SourceDeadError(f"{source.name} 凭据失效/欠费: {status}", **ctx) 摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
if status == 400: 看到的""下游 catch 到的"对不上,排查时反而多一层困惑。
return RequestRejectedError(f"{source.name} 请求被拒: 400", **ctx) """
summary = summarize_body(body_text)
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"body_text": summary,
}
if status == 429: if status == 429:
return _translate_429(source, body_text, headers) return _translate_429(source, body_text, headers, ctx)
if status >= 500: cls, label = _classify(status)
return TransientError(f"{source.name} 瞬时错误: {status}", **ctx) return cls(compose_message(f"{source.name} {label}: {status}", summary), **ctx)
return RequestRejectedError(f"{source.name} 客户端错误: {status}", **ctx)
def _strip_think(content: str) -> tuple[str, str]: def _strip_think(content: str) -> tuple[str, str]:
+110
View File
@@ -6,6 +6,8 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
import dataclasses import dataclasses
import json import json
import math
import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from types import MappingProxyType from types import MappingProxyType
@@ -31,6 +33,18 @@ USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
_EST_TOKENS_QUOTA_DIVISOR = 60 _EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。""" """未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
_TENANT_ID_MAX_LEN = 128
_META_MAX_KEYS = 16
_META_VALUE_MAX_LEN = 256
_META_RESERVED_PREFIX = "pg_"
_META_KEY_RE = re.compile(r"[a-z0-9_.]{1,64}")
"""调用方维度的形态上限(issue #11 §4.2)。
数值取自同类系统的量级(Loki labels 15 / Salesforce 自定义索引 25 /
Sentry tag 200 字符),非本项目实测;键字符集照搬 OTel semconv。
`pg_` 前缀留给库将来的内建维度——同类做法见 LangSmith 的 `ls_`、
Traceloop 的 `traceloop.`;本版库自身不写入任何该前缀的键。"""
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]: def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。 """校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
@@ -59,6 +73,87 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict
return dict(overlay) return dict(overlay)
def validate_caller_dimensions(
tenant_id: str | None,
meta: Mapping[str, Any] | None,
*,
origin: str,
) -> tuple[str | None, dict[str, Any]]:
"""校验调用方自定义维度并返回浅拷贝;origin 用于把错误指回调用点(issue #11 §4.2)。
一切超限**报错而非静默丢弃**(P5): 同类系统里 Langfuse 对超长 value 直接
丢掉,那会让调用方以为记上了而实际没有。报错点必须在进洋葱之前——洋葱内
的失败都被遥测层降级成 warning,校验放那里等于没有校验。
"""
_validate_tenant_id(tenant_id, origin)
if meta is None:
return tenant_id, {}
# 键形态须先于值校验: 非 str 键若拖到值校验之后,会以更晦涩的形态报出来
_validate_meta_keys(meta, origin)
_validate_meta_values(meta, origin)
return tenant_id, dict(meta)
def _validate_tenant_id(tenant_id: str | None, origin: str) -> None:
"""租户标识形态;首尾空白**拒绝而非 strip**(见函数体注释)。"""
if tenant_id is None:
return
if not isinstance(tenant_id, str):
raise ValueError(f"{origin} 的 tenant_id 必须是 str: {tenant_id!r}")
# 悄悄 strip 会让 " t1" 变成 "t1": 二者在 RLS policy 的等值比较下是两个
# 不同租户,替调用方改写值等于把它的行藏进另一个租户,且不报错
if tenant_id != tenant_id.strip():
raise ValueError(
f"{origin} 的 tenant_id 不得含首尾空白: {tenant_id!r}"
"(RLS 等值比较下它与去空白版本是两个租户)"
)
if not tenant_id:
raise ValueError(f"{origin} 的 tenant_id 不得为空串(空串是未归属行的哨兵值)")
if len(tenant_id) > _TENANT_ID_MAX_LEN:
raise ValueError(f"{origin} 的 tenant_id 超长(上限 {_TENANT_ID_MAX_LEN}): {len(tenant_id)}")
def _validate_meta_keys(meta: Mapping[str, Any], origin: str) -> None:
"""键形态与数量;键集合被假定为低基数且稳定,故收紧到 OTel semconv 字符集。"""
# 数量闸先于逐键校验: 这道闸要防的正是"整个请求体被塞进 meta"的形态,
# 那时逐键正则会先跑上万次才报出真正的原因,拖慢的恰是出错路径
if len(meta) > _META_MAX_KEYS:
raise ValueError(f"{origin} 的 meta 键数超限(上限 {_META_MAX_KEYS}): {len(meta)}")
for key in meta:
if not isinstance(key, str):
raise ValueError(f"{origin} 的 meta 键必须是 str: {key!r}")
if key.startswith(_META_RESERVED_PREFIX):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 使用了保留前缀 {_META_RESERVED_PREFIX!r}"
"(留给库将来的内建维度,避免与调用方的键撞名)"
)
if not _META_KEY_RE.fullmatch(key):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 不合法: 只允许小写字母/数字/下划线/点,长度 1-64"
)
def _validate_meta_values(meta: Mapping[str, Any], origin: str) -> None:
"""值只收扁平标量;非有限 float 必须挡在这里。
`json.dumps` 会把 `nan`/`inf` 写成 `NaN`/`Infinity` 字面量——不是合法 JSON,
PG 的 JSONB 拒收。放行则写入失败会被遥测的降级 try 吞成 warning,即把调用方
的输入错误转成静默丢遥测(Codex 审查推翻了初稿"序列化不可达"的论断)。
"""
for key, value in meta.items():
if not isinstance(value, (str, int, float, bool)):
raise ValueError(
f"{origin} 的 meta 值必须是 str/int/float/bool: {key}={value!r}"
"(嵌套结构请调用方自行序列化)"
)
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"{origin} 的 meta 值不得是 nan/inf: {key}={value!r}(非合法 JSON)")
if isinstance(value, str) and len(value) > _META_VALUE_MAX_LEN:
raise ValueError(
f"{origin} 的 meta 值超长(上限 {_META_VALUE_MAX_LEN}): {key}{len(value)}"
)
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]: def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。""" """合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
return {**extra_body, **sampling} return {**extra_body, **sampling}
@@ -129,6 +224,21 @@ class ChatRequest:
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则 不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
同一列在不同行口径分叉。""" 同一列在不同行口径分叉。"""
# —— 调用方自定义维度(issue #11;追加在末尾,不扰动既有字段的位置构造)——
tenant_id: str | None = None
"""调用方的租户标识,进遥测的 `tenant_id` 真实列(issue #11)。
独立成字段而非混进 `meta`,因为它是唯一享有真实列待遇的维度——可挂 RLS、
可进复合索引。混在 `meta` 里则调用方拼错(`tenantId`)不会报错,只会静默
降级成一个普通维度,正是本 issue 抱怨的失败形态。"""
meta: Mapping[str, Any] = field(default_factory=dict)
"""调用方自定义维度的只读快照,库不解释其含义,库内中间件**永不修改**。
**不进缓存 key**: 租户隔离已由 `cache_namespace` 负责并已进 key(ARCH §7.5),
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class Usage: class Usage:
+40 -1
View File
@@ -11,7 +11,12 @@ import sqlite3
import httpx import httpx
import pytest import pytest
from polygateway import CircuitOpenError, GatewayClient, TransientError from polygateway import (
CircuitOpenError,
GatewayClient,
RequestRejectedError,
TransientError,
)
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
@@ -173,6 +178,40 @@ class TestTelemetryAcrossPaths:
assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立 assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立
class TestRejectionReasonIsQueryable:
"""issue #10 的验收主张: 400 之后,网关说的话必须能在遥测表里查到。
下游一轮 1050 张影像的批处理里,1 张在读表格时收到 400 被判确定性失败,
事后"这张图到底哪里不合规"无从查起——响应体在 transport 翻译层就没了。
"""
# issue #10 原文给出的真实响应体(一字不改)
_BODY = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
client = _full_client(
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
)
with pytest.raises(RequestRejectedError):
await client.chat([{"role": "user", "content": "hi"}])
recorder.close()
rows = sqlite3.connect(tmp_path / "t.db").execute("SELECT error FROM llm_calls").fetchall()
assert rows, "400 必须留下遥测行(遥测必录)"
errors = " ".join(r[0] or "" for r in rows)
# 修复前这里只有 "qwen_1 请求被拒: 400"——诊断信息一个字都不在
assert "InvalidParameter" in errors
assert "The image format is illegal" in errors
# 尾部的 code 才是向网关方追查的凭据,头部硬切正好会丢掉它
assert "invalid_parameter_error" in errors
class TestStructuredThroughStack: class TestStructuredThroughStack:
async def test_feedback_reask_passes_through_governance(self): async def test_feedback_reask_passes_through_governance(self):
"""重问经过内层治理: 第二次真实请求同样被限流/熔断记账。""" """重问经过内层治理: 第二次真实请求同样被限流/熔断记账。"""
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import re
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -44,6 +45,8 @@ _EXPECTED_COLUMNS = [
"model_reported", "model_reported",
"sampling", "sampling",
"reasoning_tokens", "reasoning_tokens",
"tenant_id",
"meta",
] ]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见 # run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
@@ -109,6 +112,9 @@ async def _record_minimal(
"model_reported": None, "model_reported": None,
"sampling": None, "sampling": None,
"reasoning_tokens": None, "reasoning_tokens": None,
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -299,3 +305,326 @@ class TestDegradation:
await _record_minimal(recorder) await _record_minimal(recorder)
await recorder.aclose() await recorder.aclose()
await recorder.aclose() await recorder.aclose()
_PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据
@pytest.fixture
async def least_privilege_dsn(dsn):
"""临时 schema + 临时角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
这是 issue #9 的现场——最小权限部署的标准形态。fixture 建的一切
(schema、表、角色)都在 teardown 里删净,共享的 public.llm_calls 不受影响;
连不上或无权建角色(非超级用户)时 skip,不让 CI 假绿。
"""
import asyncpg
from polygateway.telemetry.postgres import _DDL
name = f"pgwtest_lp_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10)
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(f"SET search_path = {name}")
await admin.execute(_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:
"""issue #9: 只有表级写权限的账号,遥测必须照常落库而不是整体判死。"""
async def test_create_table_if_not_exists_is_denied_for_this_role(self, least_privilege_dsn):
"""库外事实先钉死: 表存在、写得进去,DDL 仍被拒——PG 的权限检查早于 IF NOT EXISTS。
修复依赖的是这条 PG 语义;若某天它变了,这里先红,而不是让下面那条
用例悄悄变成"永远通过"的空断言。
"""
import asyncpg
low_dsn, _ = least_privilege_dsn
conn = await asyncpg.connect(low_dsn, timeout=10)
try:
assert await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("CREATE TABLE IF NOT EXISTS llm_calls (call_id TEXT)")
finally:
await conn.close()
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
low_dsn, schema = least_privilege_dsn
recorder = PostgresRecorder(low_dsn)
try:
await _record_minimal(recorder, call_id=_cid("lp1"))
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
assert recorder._failed is False # 判死开关不得被建表权限触发
rows = await _fetch(
low_dsn,
"SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
f"{_RUN_PREFIX}-lp%",
)
assert [(r["call_id"], r["cost"]) for r in rows] == [
(_cid("lp1"), None),
(_cid("lp2"), 1.5),
]
assert schema # teardown 会连表带角色删净
finally:
await recorder.aclose()
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
_PRE_TENANT_DDL = """
CREATE TABLE {schema}.llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
)
"""
_PRE_TENANT_INSERT = (
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
)
def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
@pytest.fixture
async def captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
@pytest.fixture
async def pre_tenant_schema(dsn):
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。
"""
import asyncpg
name = f"pgwtest_pre_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture
async def fresh_schema(dsn):
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
import asyncpg
name = f"pgwtest_new_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture
async def least_privilege_pre_tenant_dsn(dsn):
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
"""
import asyncpg
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10)
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
yield _search_path_dsn(low, name)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
class TestCallerDimensionsAcceptance:
"""issue #11 的机械化验收(PG 侧,真实实例): 新建库 / 旧表补列 / 补列失败方向。"""
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
fresh_dsn, schema = fresh_schema
recorder = PostgresRecorder(fresh_dsn)
try:
await _record_minimal(
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
)
cols = await _fetch(
fresh_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
fresh_dsn,
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
_cid("dim"),
)
assert rows[0]["tenant_id"] == "tenant-a"
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
finally:
await recorder.aclose()
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(
self, pre_tenant_schema
):
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
审计出来,历史欠账是可见、可量化、可补录的。
"""
schema_dsn, schema = pre_tenant_schema
recorder = PostgresRecorder(schema_dsn)
try:
await _record_minimal(
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
schema_dsn,
"SELECT call_id, tenant_id, meta FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("new"), _cid("old")],
)
by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id[_cid("old")]["meta"]) == {}
finally:
await recorder.aclose()
async def test_alter_is_denied_for_a_role_that_can_still_insert(
self, least_privilege_pre_tenant_dsn
):
"""库外事实先钉死: 表存在、写得进去,补列的 ALTER 仍被拒(ownership 检查早于存在性判断)。
没有这条,下面那个降级用例可能因为 ALTER 其实成功了而变成"永远通过"的空断言。
"""
import asyncpg
conn = await asyncpg.connect(least_privilege_pre_tenant_dsn, timeout=10)
try:
assert await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS tenant_id TEXT")
finally:
await conn.close()
async def test_backfill_failure_degrades_per_row_not_wholesale(
self, least_privilege_pre_tenant_dsn, captured_warnings
):
"""补列失败的降级方向: 记 warning、不置 `_failed`、后续 INSERT 仍照发。
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
且一旦 DBA 补上列也不会自愈——必须等重启。
"""
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn)
try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
assert recorder._failed is False
assert any("补列失败" in m for m in captured_warnings)
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
assert any("写入失败" in m for m in captured_warnings)
finally:
await recorder.aclose()
+66
View File
@@ -184,6 +184,72 @@ class TestSamplingOverlay:
assert [c["seed"] for c in captured] == [1, 2] assert [c["seed"] for c in captured] == [1, 2]
class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
class TestCallerDimensions:
"""调用方自定义维度进遥测(issue #11 Task 4)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_dimensions_reach_telemetry_row(self):
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"})
row = recorder.rows[-1]
assert row["tenant_id"] == "t1"
assert json.loads(row["meta"]) == {"batch": "b-42"}
async def test_default_path_writes_sentinels(self):
"""不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG)
row = recorder.rows[-1]
assert row["tenant_id"] == "" and row["meta"] == "{}"
async def test_invalid_meta_key_rejected_before_any_telemetry(self):
"""校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
assert recorder.rows == []
async def test_non_finite_float_rejected_before_any_telemetry(self):
"""nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="nan"):
await client.chat(self._MSG, meta={"k": float("nan")})
assert recorder.rows == []
async def test_meta_does_not_enter_cache_key(self):
"""仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。
带对照组: 只断言"命中"的话,缓存 key 退化成常量(忽略一切输入)时本用例
照样绿——那是恒真断言。故再改一个**确实进 key** 的维度(namespace)断言
miss,证明 key 仍在区分输入,"meta 不进 key"才是被测出来的结论。
"""
cache = InMemoryCache() # 两个 client 共用一份存储,否则对照组的 miss 是白来的
client = _client(cache=cache, cache_namespace="proj", cache_ttl_s=3600)
async with client:
first = await client.chat(self._MSG, meta={"batch": "b-1"})
second = await client.chat(self._MSG, meta={"batch": "b-2"})
assert first.cache_hit is False and second.cache_hit is True
other_ns = _client(cache=cache, cache_namespace="other", cache_ttl_s=3600)
async with other_ns:
assert (await other_ns.chat(self._MSG, meta={"batch": "b-1"})).cache_hit is False
class TestModelFingerprint: class TestModelFingerprint:
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。""" """配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
+39
View File
@@ -412,6 +412,45 @@ class TestEmbedTelemetry:
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库 assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
class TestEmbedCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 embed 链四层透传到每一行遥测。"""
async def test_single_batch_row_carries_dimensions(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"], tenant_id="t1", meta={"batch": "b-42"})
assert rec.rows[0]["tenant_id"] == "t1"
assert rec.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_every_batch_row_carries_the_same_dimensions(self):
"""维度属于本次 `embed()` 调用,不随批次变化。
只断言首行会漏掉"只有第一批带维度"的实现——那正是逐层透传最容易漏的形态。
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=1, telemetry=rec)
await client.embed(["a", "b", "c"], tenant_id="t1", meta={"batch": "b-42"})
assert len(rec.rows) == 3 # 切成三批,每批一行
assert [r["tenant_id"] for r in rec.rows] == ["t1", "t1", "t1"]
assert [r["meta"] for r in rec.rows] == ['{"batch": "b-42"}'] * 3
async def test_invalid_meta_rejected_before_any_telemetry(self):
"""校验在切批之前: 遥测层的失败都被降级成 warning,放下游等于没有校验。"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
with pytest.raises(ValueError, match="meta"):
await client.embed(["a"], meta={"Bad Key": 1})
assert rec.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"])
assert rec.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert rec.rows[0]["meta"] == "{}"
@contextlib.contextmanager @contextlib.contextmanager
def _captured_warnings(): def _captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。""" """捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
+38 -3
View File
@@ -34,6 +34,43 @@ class TestBaseShape:
assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5 assert TransientError("429", retry_after_s=2.5).retry_after_s == 2.5
class TestBodyText:
"""issue #10: 非 2xx 的响应体摘要必须有承载处,否则拒绝理由事后不可查。"""
@pytest.mark.parametrize(
"cls", (PolyGatewayError, TransientError, SourceDeadError, RequestRejectedError)
)
def test_defaults_empty_and_accepts_summary(self, cls):
assert cls("boom").body_text == ""
assert cls("boom", body_text='{"error":{"code":"bad"}}').body_text == (
'{"error":{"code":"bad"}}'
)
def test_result_invalid_keeps_both_fields_apart(self):
"""`body_text`(非 2xx 的拒绝理由)与 `raw_text`(2xx 的不可解析输出)不得混用。"""
exc = ResultInvalidError("bad json", raw_text="{oops", body_text="")
assert exc.raw_text == "{oops"
assert exc.body_text == ""
@pytest.mark.parametrize(
"exc",
(
AllSourcesExhausted(scope="llm", reason="stalled", retry_after_s=1.0),
CircuitOpenError(scope="llm", retry_after_s=1.0),
GovernanceBackendError("redis down", scope="llm"),
),
)
def test_scope_level_errors_carry_no_body(self, exc):
"""scope 级失败没有单一响应体可言,空串是如实表达而非噪音。"""
assert exc.body_text == ""
def test_body_text_does_not_leak_into_str(self):
"""字段是旁路数据: 加了它不得改变任何既有异常的 str() 输出。"""
assert str(RequestRejectedError("qwen_1 请求被拒: 400", body_text="whatever")) == (
"qwen_1 请求被拒: 400"
)
class TestResultInvalid: class TestResultInvalid:
def test_carries_diagnosis(self): def test_carries_diagnosis(self):
exc = ResultInvalidError( exc = ResultInvalidError(
@@ -134,9 +171,7 @@ class TestGovernanceBackendReason:
assert "governance_backend_down" in SCOPE_REASONS assert "governance_backend_down" in SCOPE_REASONS
def test_gateway_unavailable_accepts_the_new_reason(self): def test_gateway_unavailable_accepts_the_new_reason(self):
exc = AllSourcesExhausted( exc = AllSourcesExhausted(scope="LLM", reason="governance_backend_down", retry_after_s=0.0)
scope="LLM", reason="governance_backend_down", retry_after_s=0.0
)
assert exc.reason == "governance_backend_down" assert exc.reason == "governance_backend_down"
def test_retry_after_default_is_non_zero(self): def test_retry_after_default_is_non_zero(self):
+89
View File
@@ -0,0 +1,89 @@
"""HTTP 错误响应体摘要口径(issue #10 设计 §3.2/§3.4)。
摘要是 message 与 `body_text` 共用的**同一份串**,故它的边界行为直接决定
遥测里看到的与下游 catch 到的是否一致——本组用例把规则钉成算术。
"""
import httpx
import pytest
from polygateway.transports._http_errors import (
_ERROR_BODY_CAP,
_HEAD_CHARS,
_TAIL_CHARS,
compose_message,
response_body,
summarize_body,
)
# issue #10 原文给出的真实响应体(一字不改),关键在于 code 收尾
_REAL_SAMPLE = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
class TestSummarizeBody:
def test_short_body_passes_through(self):
assert summarize_body(_REAL_SAMPLE) == _REAL_SAMPLE
def test_whitespace_collapsed(self):
"""错误体常是缩进 JSON: 不折叠会把一行日志炸成多行、遥测列不可读。"""
assert (
summarize_body('{\n "error": {\n "code": "x"\n }\n}')
== '{ "error": { "code": "x" } }'
)
@pytest.mark.parametrize("raw", ("", " ", "\n\t \n"))
def test_blank_yields_empty(self, raw):
assert summarize_body(raw) == ""
def test_exactly_at_cap_is_untouched(self):
body = "x" * _ERROR_BODY_CAP
assert summarize_body(body) == body
def test_one_over_cap_is_summarized(self):
summary = summarize_body("x" * (_ERROR_BODY_CAP + 1))
assert summary != "x" * (_ERROR_BODY_CAP + 1)
assert "" in summary
def test_head_and_tail_both_survive(self):
"""头部硬切会丢掉尾部,而 JSON 错误体的 code/request_id 正在尾部。"""
body = "H" * 5000 + "T" * 5000
summary = summarize_body(body)
assert summary[:_HEAD_CHARS] == body[:_HEAD_CHARS]
assert summary[-_TAIL_CHARS:] == body[-_TAIL_CHARS:]
assert f"…(略 {10000 - _HEAD_CHARS - _TAIL_CHARS} 字)…" in summary
def test_real_sample_tail_visible_in_oversized_body(self):
"""设计 §7 用例 3c: 超长体里,追查网关方所需的 code 仍须可见。"""
summary = summarize_body("PADDING" * 1000 + _REAL_SAMPLE)
assert '"code":"invalid_parameter_error"}}' in summary
def test_idempotent(self):
"""再摘要一次不得嵌套标记,否则重复经手的串会层层套娃。"""
once = summarize_body("y" * 9999)
assert summarize_body(once) == once
class TestComposeMessage:
def test_empty_summary_leaves_message_intact(self):
assert compose_message("qwen_1 请求被拒: 400", "") == "qwen_1 请求被拒: 400"
def test_non_empty_summary_is_appended(self):
assert compose_message("qwen_1 请求被拒: 400", "{}") == "qwen_1 请求被拒: 400 | {}"
class TestResponseBody:
def test_reads_buffered_text(self):
assert response_body(httpx.Response(400, content=b'{"e":1}')) == '{"e":1}'
def test_unread_stream_degrades_to_empty(self):
"""取不到诊断信息绝不能升级为崩溃: 未读缓冲返回空串,且不触发网络读。"""
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
assert response_body(httpx.Response(400, stream=_Unread())) == ""
+44 -1
View File
@@ -19,7 +19,11 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.transports.monkey_ocr import MonkeyOcrTransport, _parse_middle_json from polygateway.transports.monkey_ocr import (
MonkeyOcrTransport,
_classify_status,
_parse_middle_json,
)
from polygateway.types import SourceConfig from polygateway.types import SourceConfig
@@ -306,6 +310,45 @@ class TestErrorTranslation:
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1") await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
assert ei.value.status_code == status assert ei.value.status_code == status
@pytest.mark.parametrize(
("status", "exc_type"),
[
(502, TransientError),
(429, TransientError), # 与 5xx 共用分支,但仍单列: 漏分支正是 issue #10 的成因
(401, SourceDeadError),
(404, RequestRejectedError),
],
)
async def test_body_survives_every_branch(self, status, exc_type):
"""issue #10: OCR 侧 message 原本只有 HTTP 状态码,拒绝理由同样丢失。"""
body = '{"detail":"unsupported image mode CMYK"}'
t = _transport_for(_routes(text_resp=httpx.Response(status, content=body.encode())))
with pytest.raises(exc_type) as ei:
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
assert ei.value.body_text == body
assert str(ei.value).endswith(f" | {body}")
def test_unread_body_degrades_without_changing_class(self):
"""取不到 body 时降级空串: 绝不能让 ResponseNotRead 逃出错误四分类。
直接测纯函数而非走 MockTransport——真实客户端对非 stream 请求总会读完
响应,未读态只可能在将来给 OCR 加 stream 时出现,而那正是要防的场景。
"""
class _Unread(httpx.SyncByteStream):
def __iter__(self):
yield b"body"
exc = httpx.HTTPStatusError(
"404",
request=httpx.Request("POST", "http://ocr.example/ocr/text"),
response=httpx.Response(404, stream=_Unread()),
)
err = _classify_status(exc, "monkey_1", "text")
assert isinstance(err, RequestRejectedError)
assert err.body_text == ""
assert str(err) == "monkey_1 OCR text HTTP 404"
async def test_connect_error_transient(self): async def test_connect_error_transient(self):
def handler(request): def handler(request):
raise httpx.ConnectError("refused", request=request) raise httpx.ConnectError("refused", request=request)
+53
View File
@@ -480,6 +480,59 @@ class TestTelemetry:
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣 assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
class TestOcrCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 OCR 链四层透传到每一行遥测。
OCR 行与 chat 行落在同一张 `llm_calls` 表: 不覆盖这条链会让同一张表里
一部分行有租户归属、一部分永远空白,而"先启用后加列则归属无法还原"
"""
async def test_recognize_text_row_carries_dimensions(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert recorder.rows[0]["tenant_id"] == "t1"
assert recorder.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_parse_layout_row_carries_dimensions(self):
"""两个公共方法都是入口: 只测一个会漏掉另一个的透传缺口。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["layout"], telemetry=recorder)
await client.parse_layout(b"jpg", tenant_id="t2", meta={"batch": "b-43"})
assert recorder.rows[0]["tenant_id"] == "t2"
assert recorder.rows[0]["meta"] == '{"batch": "b-43"}'
async def test_failed_attempt_row_also_carries_dimensions(self):
"""失败行同样需要归属: 某租户的请求没被服务,正是审计最需要的一行。"""
recorder = _MemoryRecorder()
client, _, _ = _client(
[_src()],
[TransientError("boom", status_code=500), "text"],
telemetry=recorder,
)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert len(recorder.rows) == 2 # 失败尝试 + 成功尝试
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
with pytest.raises(ValueError, match="meta"):
await getattr(client, method)(b"jpg", meta={"Bad Key": 1})
assert recorder.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg")
assert recorder.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert recorder.rows[0]["meta"] == "{}"
class TestAssembly: class TestAssembly:
_ENV = { _ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866", "OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
+120
View File
@@ -16,6 +16,7 @@ from polygateway.errors import (
) )
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable from polygateway.pricing import ModelPrice, PricingTable
from polygateway.transports._http_errors import summarize_body
from polygateway.transports.openai_compat import ( from polygateway.transports.openai_compat import (
OpenAICompatTransport, OpenAICompatTransport,
_iter_sse_deltas, _iter_sse_deltas,
@@ -691,6 +692,125 @@ class TestErrorTranslation:
await _complete(_transport_for(handler), _source()) await _complete(_transport_for(handler), _source())
# issue #10 原文给出的真实响应体(一字不改): 关键在于 code 收尾
_REJECT_BODY = (
'{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal '
'and cannot be opened","type":"invalid_request_error","param":"",'
'"code":"invalid_parameter_error"}}'
)
class TestErrorBodyRetention:
"""issue #10: 网关说了什么必须活着离开翻译层——message 与字段各留一份。"""
@pytest.mark.parametrize(
("status", "exc"),
[
(400, RequestRejectedError),
(401, SourceDeadError),
(403, SourceDeadError),
(404, RequestRejectedError),
(500, TransientError),
(503, TransientError),
],
)
async def test_every_non_2xx_branch_keeps_the_body(self, status, exc):
def handler(request):
return httpx.Response(status, content=_REJECT_BODY.encode())
with pytest.raises(exc) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.body_text == _REJECT_BODY
# message 与字段共用同一份串: 遥测里看到的与下游 catch 到的不得打架
assert str(ei.value).endswith(f" | {_REJECT_BODY}")
assert "invalid_parameter_error" in str(ei.value)
async def test_rate_limited_429_keeps_body_and_retry_after(self):
def handler(request):
return httpx.Response(
429,
content=b'{"error":{"message":"per-minute cap 3"}}',
headers={"retry-after": "2.5"},
)
with pytest.raises(TransientError) as ei:
await _complete(_transport_for(handler), _source())
assert "per-minute cap 3" in str(ei.value)
assert ei.value.retry_after_s == 2.5 # 摘要不得干扰既有解析
async def test_insufficient_quota_429_keeps_body(self):
body = json.dumps(
{"error": {"type": "insufficient_quota", "message": "daily budget spent"}}
)
def handler(request):
return httpx.Response(429, content=body.encode())
with pytest.raises(SourceDeadError) as ei:
await _complete(_transport_for(handler), _source())
assert "daily budget spent" in str(ei.value)
async def test_oversized_insufficient_quota_still_classified_dead(self):
"""实现红线: 类型判定必须读**原文**。
摘要会破坏 JSON 结构,若改用摘要解析,超长 body 的配额耗尽将退化成普通
限速——配额已耗尽的源不再 force_open,一个诊断改进就变成了治理 bug。
**填充必须是多个键**,不能是单个超长字符串值: 后者的截断点落在字符串
*内部*,省略标记成了合法的字符串内容,而头尾保留又让尾部的 error 对象
幸存——摘要照样解析得出 `insufficient_quota`,用例即告空转(2026-08-16
verifier 变异测试发现: 按错误写法实现,全套件 824 项依然全绿)。多键
填充让截断点落在结构记号之间,摘要才真正不可解析。
"""
body = json.dumps(
{**{f"k{i}": "v" * 10 for i in range(300)}, "error": {"type": "insufficient_quota"}}
)
assert len(body) > 2048
with pytest.raises(json.JSONDecodeError):
# 判别力的前提: 摘要确实不再是合法 JSON,读它必然拿不到 type
json.loads(summarize_body(body))
def handler(request):
return httpx.Response(429, content=body.encode())
with pytest.raises(SourceDeadError):
await _complete(_transport_for(handler), _source())
async def test_empty_body_leaves_no_dangling_separator(self):
def handler(request):
return httpx.Response(400, content=b"")
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source())
assert str(ei.value) == "qwen_1 请求被拒: 400"
assert ei.value.body_text == ""
@pytest.mark.parametrize("body", [b"<html>gateway down</html>", b"\xff\xfe not utf-8"])
async def test_non_json_and_non_utf8_bodies_do_not_explode(self, body):
def handler(request):
return httpx.Response(400, content=body)
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source())
assert ei.value.status_code == 400 # 分类不受 body 形态影响
async def test_non_stream_path_keeps_the_body(self):
def handler(request):
return httpx.Response(400, content=_REJECT_BODY.encode())
with pytest.raises(RequestRejectedError) as ei:
await _complete(_transport_for(handler), _source(), stream=False)
assert ei.value.body_text == _REJECT_BODY
async def test_embedding_path_keeps_the_body(self):
def handler(request):
return httpx.Response(400, content=_REJECT_BODY.encode())
with pytest.raises(RequestRejectedError) as ei:
await _transport_for(handler).embed(texts=["hi"], source=_source(), call_id="cid-embed")
assert ei.value.body_text == _REJECT_BODY
class TestLifecycle: class TestLifecycle:
async def test_aclose_idempotent(self): async def test_aclose_idempotent(self):
def handler(request): def handler(request):
+25
View File
@@ -118,6 +118,8 @@ class _DummyRecorder:
model_reported, model_reported,
sampling, sampling,
reasoning_tokens, reasoning_tokens,
tenant_id,
meta,
) -> None: ... ) -> None: ...
@@ -205,6 +207,29 @@ class TestGateUpdate:
) )
class TestTelemetryRecorderSignature:
"""`record_llm_call` 的冻结签名以 `inspect.signature` 实测,不凭记忆断言。
该 Protocol 的纪律是新增参数**不设默认值**(ports.py docstring):库外无第三方
实现者,而带默认值的参数会让 emitter 漏传时静默落默认值——遥测里的租户归属
一旦静默错位,事后无从分辨是"没传"还是"就是空的"
"""
def test_caller_dimensions_are_declared(self):
import inspect
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
assert {"tenant_id", "meta"} <= set(params)
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
def test_caller_dimensions_have_no_default(self, name):
import inspect
param = inspect.signature(TelemetryRecorder.record_llm_call).parameters[name]
assert param.default is inspect.Parameter.empty
assert param.kind is inspect.Parameter.KEYWORD_ONLY
class TestOcrPorts: class TestOcrPorts:
"""M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。""" """M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。"""
+383 -3
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import os
import sqlite3 import sqlite3
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -40,6 +41,8 @@ _EXPECTED_COLUMNS = [
"model_reported", "model_reported",
"sampling", "sampling",
"reasoning_tokens", "reasoning_tokens",
"tenant_id",
"meta",
] ]
@@ -104,11 +107,38 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
"model_reported": None, "model_reported": None,
"sampling": None, "sampling": None,
"reasoning_tokens": None, "reasoning_tokens": None,
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
class TestBackendColumnParity:
"""两个后端的 `_COLUMNS` 必须逐字同名同序(issue #11)。
emitter 只组装一份 `fields`,两个后端各自按自己的 `_COLUMNS` 取值;两份清单
一旦分叉,同一次调用在 SQLite 上写得进、在 PG 上抛 KeyError 被降级吞掉,
差异只在换后端时才暴露。列**序**同样断言: INSERT 用位置占位符,顺序错位
会把值写进错误的列而不报错。
"""
def test_two_backends_agree_on_columns(self):
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
assert SQLITE_COLUMNS == PG_COLUMNS
def test_caller_dimensions_are_appended_last(self):
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
assert SQLITE_COLUMNS[-2:] == ("tenant_id", "meta")
assert PG_COLUMNS[-2:] == ("tenant_id", "meta")
class TestSQLiteRecorder: class TestSQLiteRecorder:
async def test_schema_has_frozen_columns(self, tmp_path): async def test_schema_has_frozen_columns(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db") recorder = SQLiteRecorder(tmp_path / "t.db")
@@ -256,18 +286,171 @@ class TestSQLiteColumnBackfill:
recorder.close() recorder.close()
class _FakePgConn: # issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
"""记录执行过的语句;可让 ALTER 抛错以模拟权限不足。""" _PRE_TENANT_DDL = """
CREATE TABLE llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
);
"""
def __init__(self, existing: list[str], *, fail_alter: bool = False): _PRE_TENANT_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ('old-row', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
)
def _make_pre_tenant_db(path: Path) -> None:
"""造一个 issue #11 之前的库: 22 字段旧表 + 一行没有租户归属的历史数据。"""
conn = sqlite3.connect(path)
conn.execute(_PRE_TENANT_DDL)
conn.execute(_PRE_TENANT_INSERT)
conn.commit()
conn.close()
class TestSQLiteCallerDimensionsAcceptance:
"""issue #11 的机械化验收(SQLite 侧,真实临时文件): 新建库 / 旧表补列 / 补列失败方向。"""
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
db = tmp_path / "fresh.db"
recorder = SQLiteRecorder(db)
await _record_minimal(
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
)
recorder.close()
conn = sqlite3.connect(db)
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
row = conn.execute(
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = 'c-dim'"
).fetchone()
assert row[0] == "tenant-a"
assert json.loads(row[1]) == {"batch": "b7"}
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(self, tmp_path):
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
审计出来,历史欠账是可见、可量化、可补录的。
"""
db = tmp_path / "pre_tenant.db"
_make_pre_tenant_db(db)
recorder = SQLiteRecorder(db)
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
recorder.close()
conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert (
conn.execute("SELECT meta FROM llm_calls WHERE call_id = 'old-row'").fetchone()[0]
== "{}"
)
async def test_readonly_file_backfill_failure_keeps_the_recorder_alive(self, tmp_path):
"""补列失败的降级方向(SQLite 等价构造: 文件只读)。
SQLite 没有角色权限模型,与 PG「只有 SELECT/INSERT 权限的角色」等价的构造
是文件本身只读。库文件必须**预先置为 WAL 且干净关闭**,否则 `__init__` 的
`PRAGMA journal_mode=WAL` 会先撞上只读而让失败点跑到补列之前,测不到本用例
要测的那条分支(实测: 非 WAL 库 chmod 444 后该 PRAGMA 报 readonly database)。
只读库连 INSERT 都做不了,故这里**只断言**补列失败不清空 `_conn`、不抛出
`__init__`(sqlite.py `_backfill_columns` 那条纪律),不断言"写入仍成功"
"""
if os.geteuid() == 0:
pytest.skip("root 无视文件权限位,只读构造不成立")
db = tmp_path / "readonly.db"
conn = sqlite3.connect(db)
conn.execute("PRAGMA journal_mode=WAL") # 预置 WAL: 让只读连接不必改日志模式
conn.execute(_PRE_TENANT_DDL)
conn.execute(_PRE_TENANT_INSERT)
conn.commit()
conn.close()
db.chmod(0o444)
# finally 还原权限位: 任一断言先失败时,不还原会让 tmp_path 清理连带报错,
# 把"某条断言失败"的真因盖成一个无关的 PermissionError
try:
recorder = SQLiteRecorder(db) # 不得抛
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
recorder.close()
finally:
db.chmod(0o644)
stale = sqlite3.connect(db)
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
_EXPECTED_COLUMNS[:-2]
) # 补列确实没成功,用例不是在只读库上空转
class _FakePgConn:
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
`existing` 为空列表即表示**表不存在**(与真实 PG 一致: `to_regclass` 为 NULL
时列探测必然零行),故 `fetchval` 与 `fetch` 共用同一份事实。
"""
def __init__(
self,
existing: list[str],
*,
fail_alter: bool = False,
fail_create: bool = False,
probe_errors: int = 0,
):
self.existing = existing self.existing = existing
self.fail_alter = fail_alter self.fail_alter = fail_alter
self.fail_create = fail_create
self.probe_errors = probe_errors
self.statements: list[str] = [] self.statements: list[str] = []
async def execute(self, sql, *args): async def execute(self, sql, *args):
self.statements.append(sql) self.statements.append(sql)
if sql.startswith("ALTER TABLE") and self.fail_alter: if sql.startswith("ALTER TABLE") and self.fail_alter:
raise RuntimeError("must be owner of table llm_calls") raise RuntimeError("must be owner of table llm_calls")
if sql.lstrip().startswith("CREATE TABLE"):
if self.fail_create:
raise RuntimeError("permission denied for schema public")
self.existing = list(_EXPECTED_COLUMNS)
async def fetchval(self, sql, *args):
self.statements.append(sql)
if self.probe_errors > 0:
self.probe_errors -= 1
raise RuntimeError("connection was closed in the middle of operation")
return "llm_calls" if self.existing else None
async def fetch(self, sql, *args): async def fetch(self, sql, *args):
self.statements.append(sql) self.statements.append(sql)
@@ -303,6 +486,8 @@ class TestPostgresBackfillDiscipline:
"model_reported", "model_reported",
"sampling", "sampling",
"reasoning_tokens", "reasoning_tokens",
"tenant_id",
"meta",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -338,6 +523,78 @@ class TestPostgresBackfillDiscipline:
assert all("IF NOT EXISTS" not in s for s in altered) # 探测已确认缺列,无需再判 assert all("IF NOT EXISTS" not in s for s in altered) # 探测已确认缺列,无需再判
class TestPostgresTableProbe:
"""建表必须先探测,且"判死"只认"确定写不进去"(issue #9)。
实测(PostgreSQL 16.14,只有表级 SELECT/INSERT 的角色): `CREATE TABLE IF NOT
EXISTS` 被拒 permission denied for schema,而同一连接的 `INSERT` 通过——
PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断。无条件发
DDL 会让这类最小权限部署的整个进程静默失遥测。
"""
_CURRENT = [
"call_id",
"cost",
"created_at",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
]
def _recorder(self, conn):
from polygateway.telemetry.postgres import PostgresRecorder
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
def _created(self, conn):
return [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
async def test_existing_table_is_never_recreated(self):
"""表已存在就一条 DDL 都不发——这是权限被拒的唯一根治办法。"""
conn = _FakePgConn(self._CURRENT)
await _record_minimal(self._recorder(conn))
assert not self._created(conn)
async def test_create_denied_on_existing_table_keeps_recording(self):
"""就算 DDL 仍被发出并被拒,表存在时也不得判死整个 recorder。"""
conn = _FakePgConn(self._CURRENT, fail_create=True)
recorder = self._recorder(conn)
await _record_minimal(recorder) # 不得抛
assert recorder._failed is False
assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements)
async def test_missing_table_is_created_and_not_backfilled(self):
"""表不存在→建表;新建表列已齐全,不得再发补列 ALTER。"""
conn = _FakePgConn([])
recorder = self._recorder(conn)
await _record_minimal(recorder)
assert len(self._created(conn)) == 1
assert not [s for s in conn.statements if s.startswith("ALTER TABLE")]
assert recorder._failed is False
assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements)
async def test_create_failure_on_missing_table_degrades_to_noop(self):
"""表确定不存在且建不出来 = 确定写不进去: 此时才允许永久 no-op。"""
conn = _FakePgConn([], fail_create=True)
recorder = self._recorder(conn)
await _record_minimal(recorder) # 不得抛
assert recorder._failed is True
assert not [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
async def test_probe_failure_is_transient_not_terminal(self):
"""探测失败多为连接抖动: 跳过本次,下次调用必须重试,绝不永久判死。"""
conn = _FakePgConn(self._CURRENT, probe_errors=1)
recorder = self._recorder(conn)
await _record_minimal(recorder, call_id="first") # 不得抛
assert recorder._failed is False
assert not [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
await _record_minimal(recorder, call_id="second")
assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
class _MemoryRecorder: class _MemoryRecorder:
def __init__(self): def __init__(self):
self.rows = [] self.rows = []
@@ -519,6 +776,129 @@ class TestEmitterSamplingColumn:
assert rec.rows[0]["sampling"] is None assert rec.rows[0]["sampling"] is None
class TestEmitterCallerDimensions:
"""issue #11: 三个 emit 入口统一从 `request` 读维度,`_record` 落库前归一化。
维度只有一个读取点(`request`),否则同一列在三种行里口径分叉——那正是
"遥测调用点收敛为单一 helper"这条铁律要防的形态。
"""
_META = {"z_last": "z", "a_first": 1, "m_mid": True}
_REQ_A = ChatRequest(
messages=[{"role": "user", "content": "hi"}],
session_id="sess-1",
tenant_id="tenant-a",
meta=_META,
)
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
async def test_every_entry_point_carries_the_dimensions(self, emit):
"""三条路径写出的行都必须带维度: 漏掉任一条,该租户的账就永远对不上。"""
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec)
if emit == "attempt":
await emitter.emit_attempt(
request=self._REQ_A,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(),
error=None,
)
elif emit == "cache_hit":
await emitter.emit_cache_hit(request=self._REQ_A, response=_resp(cache_hit=True))
else:
await emitter.emit_terminal_failure(
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
)
row = rec.rows[0]
assert row["tenant_id"] == "tenant-a"
assert json.loads(row["meta"]) == self._META
async def test_cache_hit_records_the_current_caller_not_the_cached_one(self):
"""缓存命中行的维度是"本次由谁发起",不是历史那次——最容易实现反的一处。
历史那次由租户 B 发起并把响应留在了缓存里;本次由租户 A 发起并命中。
若读了历史那次的归属,租户 A 的调用会记到 B 头上,而 A 的账面凭空少一行
——两个租户的账同时错,且错得没有任何报错。
"""
historical = ChatRequest(
messages=[{"role": "user", "content": "hi"}],
tenant_id="tenant-b",
meta={"batch": "old-batch"},
)
rec = _MemoryRecorder()
mw = TelemetryMW(TelemetryEmitter(rec))
async def terminal(request):
# 缓存层回放的是历史那次的响应对象(其 call_id 属于 historical 那次)
return _resp(cache_hit=True, latency_ms=0, call_id="cache-cid")
assert historical.tenant_id == "tenant-b" # 历史归属确实不同,否则本用例是空转
await mw(self._REQ_A, terminal)
row = rec.rows[0]
assert row["cache_hit"] is True
assert row["tenant_id"] == "tenant-a"
assert "old-batch" not in row["meta"]
async def test_absent_dimensions_land_as_sentinels(self):
"""未传维度落哨兵值: `tenant_id` 空串、`meta` 字面量 `'{}'`,都不是 NULL。
NULL 的 `tenant_id` 在 PG 的 RLS policy 下对所有人永久不可见(设计 §4.4),
空串则可用一条 SQL 审计出还有多少行未归属;`meta` 同理,`'{}'` 可被
JSON 函数直接查询,NULL 则要每条查询都额外判空。
"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec).emit_attempt(
request=_REQ, # tenant_id=None, meta={}
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(),
error=None,
)
row = rec.rows[0]
assert row["tenant_id"] == ""
assert row["meta"] == "{}"
async def test_meta_is_serialized_with_sorted_keys(self):
"""键序固定,同一份维度在任意两行里字节一致,可直接做等值比对与去重。"""
rec = _MemoryRecorder()
await TelemetryEmitter(rec).emit_terminal_failure(
request=self._REQ_A, call_id="c", latency_ms=1, error="dead"
)
assert list(json.loads(rec.rows[0]["meta"])) == ["a_first", "m_mid", "z_last"]
async def test_non_ascii_meta_stays_readable(self):
"""`ensure_ascii=False`: 中文维度按原文落库,而非 `\\uXXXX` 转义串。"""
rec = _MemoryRecorder()
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], meta={"dept": "研发"})
await TelemetryEmitter(rec).emit_terminal_failure(
request=req, call_id="c", latency_ms=1, error="dead"
)
assert "研发" in rec.rows[0]["meta"]
async def test_non_finite_meta_value_drops_the_row_instead_of_poisoning_it(self):
"""入口失守时 `allow_nan=False` 的真实结果: 整行降级丢弃,且不抛给调用方。
直接构造带 `nan` 的 `ChatRequest`(绕过 `validate_caller_dimensions` 这道
主防线,模拟将来某个新入口忘记校验)。没有 `allow_nan=False` 时,
`json.dumps` 会写出裸 `NaN` 字面量——PG 的 JSONB 会拒收,但 **SQLite 的
`meta` 是 TEXT 列不做校验**,那串非法 JSON 会被静默存进去,污染此后一切
按 JSON 解析 meta 的分析。宁可丢一行遥测,也不要一行毒数据。
同时断言不抛: 遥测的降级方向是"静默降级"(铁律),把调用方的一次正常
业务调用因为一个维度值炸掉,方向反了。
"""
rec = _MemoryRecorder()
req = ChatRequest(messages=[{"role": "user", "content": "hi"}], meta={"k": float("nan")})
await TelemetryEmitter(rec).emit_terminal_failure(
request=req, call_id="c", latency_ms=1, error="dead"
)
assert rec.rows == []
class TestCostWithCachedTier: class TestCostWithCachedTier:
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。""" """issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
+129
View File
@@ -398,3 +398,132 @@ class TestSourceConfigExtraBody:
""" """
with pytest.raises(TypeError): with pytest.raises(TypeError):
hash(_make_source()) hash(_make_source())
class TestCallerDimensionsValidation:
"""调用方自定义维度的入口校验(issue #11 设计 §4.2)。
这些红线全部要求**报错**而非静默丢弃: Langfuse 对超长 value 的做法是
直接丢掉,本库不抄——P5 严禁默认值掩盖错误。且报错点必须在进洋葱之前,
洋葱内的一切失败都会被遥测层降级成 warning,校验放那里等于没有校验。
"""
@pytest.mark.parametrize(
"bad",
[
"", # 空串是哨兵值的地盘(老行/未归属)
" ", # 纯空白 strip 后为空
" t1", # 首尾空白: 与 "t1" 在 RLS 等值比较下是两个租户
"t1 ",
"x" * 129, # 上限 128
123, # 非 str
],
)
def test_bad_tenant_id_rejected(self, bad):
"""租户标识形态错误必须当场报错,而非带着走到落库。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError) as exc:
validate_caller_dimensions(bad, None, origin="chat(tenant_id=...)")
assert "chat(tenant_id=...)" in str(exc.value) # 信息须能定位来源
@pytest.mark.parametrize(
"key",
[
"Batch", # 大写不合字符集
"batch-id", # 连字符不合字符集
"b" * 65, # 键长上限 64
"pg_internal", # 保留前缀
"", # 空键
],
)
def test_bad_meta_key_rejected(self, key):
"""键集合被假定为低基数且稳定,形态必须收紧(OTel semconv 字符集)。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError, match="meta"):
validate_caller_dimensions(None, {key: "v"}, origin="test")
def test_non_str_meta_key_rejected(self):
"""非 str 键无法进 JSON 对象,须先于值校验报出键的问题。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError, match="str"):
validate_caller_dimensions(None, {1: "a"}, origin="test")
def test_too_many_meta_keys_rejected(self):
"""上限 16: 容器是审计维度,不是给调用方塞整个请求体的地方。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError, match="16"):
validate_caller_dimensions(None, {f"k{i}": "v" for i in range(17)}, origin="test")
@pytest.mark.parametrize("bad", [["a"], {"a": 1}, None, object()])
def test_non_scalar_meta_value_rejected(self, bad):
"""只收扁平标量(OTel AnyValue 的可移植子集);嵌套让调用方自己序列化。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError, match=""):
validate_caller_dimensions(None, {"k": bad}, origin="test")
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_float_rejected(self, bad):
"""json.dumps 会把它们写成 NaN/Infinity 字面量——非合法 JSON,PG JSONB 拒收。
放行则调用方的输入错误会在写入层失败、被遥测降级吞成 warning,
"输入错误"静默变成"丢遥测"(Codex 审查推翻了初稿的"不可达"论断)。
"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError):
validate_caller_dimensions(None, {"k": bad}, origin="test")
def test_too_long_meta_value_rejected(self):
"""字符串值上限 256(对齐 Sentry tag / Langfuse 的量级)。"""
from polygateway.types import validate_caller_dimensions
with pytest.raises(ValueError):
validate_caller_dimensions(None, {"k": "v" * 257}, origin="test")
def test_absent_dimensions_pass_through(self):
"""两者都不传是绝大多数调用点的现状,必须零摩擦放行。"""
from polygateway.types import validate_caller_dimensions
assert validate_caller_dimensions(None, None, origin="test") == (None, {})
@pytest.mark.parametrize("good", ["s", 1, 1.5, True, False, 0])
def test_scalar_meta_values_accepted(self, good):
"""bool 是 int 子类,两者都合法;0/False 不得被真值判断误杀。"""
from polygateway.types import validate_caller_dimensions
_, meta = validate_caller_dimensions(None, {"k": good}, origin="test")
assert meta == {"k": good}
def test_returns_independent_copy(self):
"""调用方复用同一 dict 逐次改值是预期模式,不拷贝会有竞态(同 overlay 决策 E)。"""
from polygateway.types import validate_caller_dimensions
caller_dict = {"batch": "b-42"}
_, meta = validate_caller_dimensions("t1", caller_dict, origin="test")
caller_dict["batch"] = "b-99"
assert meta == {"batch": "b-42"}
class TestChatRequestDimensions:
"""ChatRequest 承载维度的字段契约(issue #11)。"""
def test_defaults_are_absent_dimensions(self):
"""新字段必须带默认值——三项目逐字段构造的 fake 才能零改动(ARCH §5.1 约定①)。"""
request = ChatRequest(messages=[{"role": "user", "content": "x"}])
assert request.tenant_id is None
assert request.meta == {}
def test_dimensions_are_carried(self):
"""维度随请求在洋葱内流转,是缓存 key 之外三个遥测入口的共同读取点。"""
request = ChatRequest(
messages=[{"role": "user", "content": "x"}],
tenant_id="t1",
meta={"batch": "b-42"},
)
assert request.tenant_id == "t1"
assert request.meta == {"batch": "b-42"}