19 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
28 changed files with 1892 additions and 41 deletions
+52
View File
@@ -1,5 +1,57 @@
# 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 翻译层之后就不存在于进程任何位置了。
+1 -1
View File
@@ -96,7 +96,7 @@ make ci # 只读验证(check + test)
| 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 页长期为空);随后打开包页面确认有正文与仓库链接,**Link to a repository 只能在网页手动做**(该实例 link API 返 404) |
| 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 条、包未挂仓库。
+51 -7
View File
@@ -16,9 +16,10 @@
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 22 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
@@ -31,7 +32,7 @@
```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.2,<2"
"polygateway[redis,postgres,structured]>=1.2.1,<2"
```
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -81,7 +82,7 @@ async def main() -> None:
await client.aclose() # 归还连接与治理后端资源
```
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
### 3. OCR 与 Embedding
@@ -112,6 +113,45 @@ except RequestRejectedError:
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
```
### 5. 多租户与自定义维度
```python
resp = await client.chat(
messages,
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
)
```
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 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 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
@@ -148,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}__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_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_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,各自独立配置与治理状态。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "polygateway"
version = "1.2.0"
version = "1.2.1"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+15 -1
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 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
**调用方维度追加(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. 错误模型
@@ -475,10 +485,14 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。
**必录字段**(继承三项目 15 字段规范): 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。
**`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`
@@ -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,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 不纳入本次。
+12
View File
@@ -160,6 +160,11 @@
"id": "plan:issue10-error-body-retention-plan",
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
"type": "plan"
},
{
"id": "plan:issue11-caller-dimensions",
"label": "调用方自定义维度实现计划(issue #11)",
"type": "plan"
}
],
"links": [
@@ -288,6 +293,13 @@
"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"
}
]
}
+7 -3
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-08-16 09:50 UTC
> 自动生成,更新时间:2026-08-17 10:09 UTC
## design (28)
## 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-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`
@@ -16,6 +16,7 @@
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -30,6 +31,7 @@
- [建表前先探测,判死只认「确定写不进去」](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`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (12)
@@ -46,7 +48,7 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (23)
## 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-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`
@@ -58,6 +60,7 @@
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
- [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`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
@@ -69,6 +72,7 @@
- [实现计划: 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`
- [推理开关能力建模与 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`
## schema (1)
+4
View File
@@ -102,3 +102,7 @@
- [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,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,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,
)
__version__ = "1.2.0"
__version__ = "1.2.1"
__all__ = [
"DEFAULT_PROFILES",
+20 -1
View File
@@ -34,7 +34,12 @@ from polygateway.sources import (
SourceCooldownMemo,
)
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:
from collections.abc import Awaitable, Iterable, Mapping
@@ -205,12 +210,18 @@ class GatewayClient:
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` ),优先级
高于源级 `extra_body`低于结构化输出的注入带默认值的 keyword-only
参数不影响既有调用点(issue #4)。
`tenant_id` `meta` 是调用方自定义维度,只进遥测**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS可进复合索引),后者是任意 KV 容器(issue #11)。
"""
if structured is not None and not self._structured_available:
raise ImportError(
@@ -221,6 +232,12 @@ class GatewayClient:
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
)
request = ChatRequest(
messages=messages,
session_id=session_id,
@@ -231,6 +248,8 @@ class GatewayClient:
stream=stream,
overlay=sampling,
sampling=sampling,
tenant_id=dimension_tenant_id,
meta=dimensions,
)
return await self._handler(request)
+79 -9
View File
@@ -21,7 +21,7 @@ import random
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from loguru import logger
@@ -47,6 +47,7 @@ from polygateway.types import (
EmbeddingResponse,
LLMResponse,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
if TYPE_CHECKING:
@@ -145,10 +146,22 @@ class EmbeddingClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> EmbeddingResponse:
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。"""
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
本次调用而非某一批,故每批的遥测行都带同一份维度
"""
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
if not texts:
return EmbeddingResponse(
vectors=[],
@@ -167,7 +180,11 @@ class EmbeddingClient:
for start in range(0, len(texts), self._batch_size):
outcomes.append(
await self._embed_batch(
texts[start : start + self._batch_size], session_id, parent_call_id
texts[start : start + self._batch_size],
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
)
)
return self._merge(outcomes)
@@ -175,7 +192,12 @@ class EmbeddingClient:
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
async def _embed_batch(
self, batch: list[str], session_id: str | None, parent_call_id: str | None
self,
batch: list[str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome:
fails = 0
reasons: dict[str, str] = {}
@@ -187,7 +209,9 @@ class EmbeddingClient:
await self._on_no_runnable(gate_rejections, reasons, clock)
continue
async with clock.attempting():
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id)
outcome = await self._attempt(
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _BatchOutcome):
return outcome
fails += 1
@@ -266,6 +290,8 @@ class EmbeddingClient:
reasons: dict[str, str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _BatchOutcome | _FailedBatch:
call_id = str(uuid.uuid4())
started = self._now()
@@ -286,17 +312,45 @@ class EmbeddingClient:
await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress())
latency_ms = int((self._now() - started) * 1000)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _BatchOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry)
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise
except asyncio.CancelledError:
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
batch, source, call_id, started, session_id, parent_call_id, error="cancelled"
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
)
raise
except (SourceDeadError, TransientError) as exc:
@@ -307,7 +361,17 @@ class EmbeddingClient:
if not dead:
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
await self._emit(
batch,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedBatch(exc, immediate=dead)
finally:
await self._settle_and_release(permit, actual)
@@ -351,16 +415,22 @@ class EmbeddingClient:
started: float,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: EmbeddingTransportResult | None = None,
error: object | None = None,
) -> None:
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
)
response = None
if result is not None:
+41 -2
View File
@@ -26,13 +26,35 @@ from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计
`allow_nan=False` **第二道闸**(主防线是 `types.validate_caller_dimensions`
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
JSON这道闸真正的价值在 **SQLite **PG JSONB 本来就会拒收 `NaN`,
SQLite `meta` TEXT **不做任何 JSON 校验**,没有这道闸就会把 `NaN`
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
`try` 内被求值,异常会被那里的 `except Exception` 接住 warning整行
遥测丢弃即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"
"""
if not meta:
return "{}"
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
@dataclass(frozen=True)
class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
@@ -72,7 +94,7 @@ class _AttemptUsage:
class TelemetryEmitter:
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
self._recorder = recorder
@@ -111,6 +133,8 @@ class TelemetryEmitter:
reasoning_tokens=usage.reasoning_tokens,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
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:
@@ -139,6 +163,11 @@ class TelemetryEmitter:
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id,
meta=request.meta,
)
async def emit_terminal_failure(
@@ -166,6 +195,9 @@ class TelemetryEmitter:
reasoning_tokens=None,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id,
meta=request.meta,
)
async def _record(
@@ -190,6 +222,9 @@ class TelemetryEmitter:
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -231,6 +266,10 @@ class TelemetryEmitter:
model_reported=model_reported,
sampling=sampling,
reasoning_tokens=reasoning_tokens,
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
)
except asyncio.CancelledError:
raise
+82 -10
View File
@@ -18,7 +18,7 @@ import random
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal
from loguru import logger
@@ -46,6 +46,7 @@ from polygateway.types import (
OcrTextResult,
Usage,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
if TYPE_CHECKING:
@@ -142,9 +143,21 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrTextResult:
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字""""
outcome = await self._call("text", image, session_id, parent_call_id)
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrTextResult(
text=result.text,
@@ -161,9 +174,20 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrLayoutResult:
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素""""
outcome = await self._call("layout", image, session_id, parent_call_id)
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"
`tenant_id` `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrLayoutResult(
elements=result.elements,
@@ -195,6 +219,8 @@ class OcrClient:
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
@@ -213,7 +239,7 @@ class OcrClient:
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _AttemptOutcome):
return outcome
@@ -294,9 +320,13 @@ class OcrClient:
reasons: dict[str, str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))
@@ -304,20 +334,47 @@ class OcrClient:
self._feed_outcome(source.name, ok=True)
latency_ms = int((self._now() - started) * 1000)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, result
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _AttemptOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise
except asyncio.CancelledError:
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
)
raise
except (SourceDeadError, TransientError) as exc:
@@ -327,7 +384,16 @@ class OcrClient:
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
self._feed_outcome(source.name, ok=False)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedAttempt(exc, immediate=dead)
finally:
@@ -389,16 +455,22 @@ class OcrClient:
started: float,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None,
) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
)
latency_ms = int((self._now() - started) * 1000)
response = None
+8 -1
View File
@@ -245,10 +245,15 @@ class StructuredOutputStrategy(Protocol):
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;22 字段冻结(M1 设计 §4.4 + issue #3/#4),唯一调用点是 TelemetryEmitter。
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
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(
@@ -276,4 +281,6 @@ class TelemetryRecorder(Protocol):
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
tenant_id: str,
meta: str,
) -> None: ...
+15 -2
View File
@@ -6,7 +6,7 @@
结构性失败 warning 一次后永久降级(所有写入短路);
运行时单条写失败 逐条 warning 丢弃,不降级不重试(连接抖动由
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)
构造不连库(lazy),22 schema SQLite 版同名同序
构造不连库(lazy),24 schema SQLite 版同名同序
**"结构性"的判据是确定写不进去,不是初始化时出过错**(issue #9):
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)"表确定不存在
@@ -48,7 +48,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
"""
@@ -58,6 +60,15 @@ _BACKFILL = (
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
# 两个默认值都是非易失常量,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 解析
@@ -92,6 +103,8 @@ _COLUMNS = (
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
)
_INSERT = (
+10 -2
View File
@@ -46,7 +46,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}'
);
"""
@@ -57,6 +59,10 @@ _BACKFILL_COLUMNS = (
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
)
_COLUMNS = (
@@ -82,6 +88,8 @@ _COLUMNS = (
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
)
_INSERT = (
@@ -137,7 +145,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。"""
if self._conn is None:
return
row = tuple(fields[col] for col in _COLUMNS)
+110
View File
@@ -6,6 +6,8 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
import dataclasses
import json
import math
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
@@ -31,6 +33,18 @@ USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
_EST_TOKENS_QUOTA_DIVISOR = 60
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
_TENANT_ID_MAX_LEN = 128
_META_MAX_KEYS = 16
_META_VALUE_MAX_LEN = 256
_META_RESERVED_PREFIX = "pg_"
_META_KEY_RE = re.compile(r"[a-z0-9_.]{1,64}")
"""调用方维度的形态上限(issue #11 §4.2)。
数值取自同类系统的量级(Loki labels 15 / Salesforce 自定义索引 25 /
Sentry tag 200 字符),非本项目实测;键字符集照搬 OTel semconv
`pg_` 前缀留给库将来的内建维度同类做法见 LangSmith `ls_`
Traceloop `traceloop.`;本版库自身不写入任何该前缀的键"""
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
@@ -59,6 +73,87 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict
return dict(overlay)
def validate_caller_dimensions(
tenant_id: str | None,
meta: Mapping[str, Any] | None,
*,
origin: str,
) -> tuple[str | None, dict[str, Any]]:
"""校验调用方自定义维度并返回浅拷贝;origin 用于把错误指回调用点(issue #11 §4.2)。
一切超限**报错而非静默丢弃**(P5): 同类系统里 Langfuse 对超长 value 直接
丢掉,那会让调用方以为记上了而实际没有报错点必须在进洋葱之前洋葱内
的失败都被遥测层降级成 warning,校验放那里等于没有校验
"""
_validate_tenant_id(tenant_id, origin)
if meta is None:
return tenant_id, {}
# 键形态须先于值校验: 非 str 键若拖到值校验之后,会以更晦涩的形态报出来
_validate_meta_keys(meta, origin)
_validate_meta_values(meta, origin)
return tenant_id, dict(meta)
def _validate_tenant_id(tenant_id: str | None, origin: str) -> None:
"""租户标识形态;首尾空白**拒绝而非 strip**(见函数体注释)。"""
if tenant_id is None:
return
if not isinstance(tenant_id, str):
raise ValueError(f"{origin} 的 tenant_id 必须是 str: {tenant_id!r}")
# 悄悄 strip 会让 " t1" 变成 "t1": 二者在 RLS policy 的等值比较下是两个
# 不同租户,替调用方改写值等于把它的行藏进另一个租户,且不报错
if tenant_id != tenant_id.strip():
raise ValueError(
f"{origin} 的 tenant_id 不得含首尾空白: {tenant_id!r}"
"(RLS 等值比较下它与去空白版本是两个租户)"
)
if not tenant_id:
raise ValueError(f"{origin} 的 tenant_id 不得为空串(空串是未归属行的哨兵值)")
if len(tenant_id) > _TENANT_ID_MAX_LEN:
raise ValueError(f"{origin} 的 tenant_id 超长(上限 {_TENANT_ID_MAX_LEN}): {len(tenant_id)}")
def _validate_meta_keys(meta: Mapping[str, Any], origin: str) -> None:
"""键形态与数量;键集合被假定为低基数且稳定,故收紧到 OTel semconv 字符集。"""
# 数量闸先于逐键校验: 这道闸要防的正是"整个请求体被塞进 meta"的形态,
# 那时逐键正则会先跑上万次才报出真正的原因,拖慢的恰是出错路径
if len(meta) > _META_MAX_KEYS:
raise ValueError(f"{origin} 的 meta 键数超限(上限 {_META_MAX_KEYS}): {len(meta)}")
for key in meta:
if not isinstance(key, str):
raise ValueError(f"{origin} 的 meta 键必须是 str: {key!r}")
if key.startswith(_META_RESERVED_PREFIX):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 使用了保留前缀 {_META_RESERVED_PREFIX!r}"
"(留给库将来的内建维度,避免与调用方的键撞名)"
)
if not _META_KEY_RE.fullmatch(key):
raise ValueError(
f"{origin} 的 meta 键 {key!r} 不合法: 只允许小写字母/数字/下划线/点,长度 1-64"
)
def _validate_meta_values(meta: Mapping[str, Any], origin: str) -> None:
"""值只收扁平标量;非有限 float 必须挡在这里。
`json.dumps` 会把 `nan`/`inf` 写成 `NaN`/`Infinity` 字面量不是合法 JSON,
PG JSONB 拒收放行则写入失败会被遥测的降级 try 吞成 warning,即把调用方
的输入错误转成静默丢遥测(Codex 审查推翻了初稿"序列化不可达"的论断)
"""
for key, value in meta.items():
if not isinstance(value, (str, int, float, bool)):
raise ValueError(
f"{origin} 的 meta 值必须是 str/int/float/bool: {key}={value!r}"
"(嵌套结构请调用方自行序列化)"
)
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"{origin} 的 meta 值不得是 nan/inf: {key}={value!r}(非合法 JSON)")
if isinstance(value, str) and len(value) > _META_VALUE_MAX_LEN:
raise ValueError(
f"{origin} 的 meta 值超长(上限 {_META_VALUE_MAX_LEN}): {key}{len(value)}"
)
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
return {**extra_body, **sampling}
@@ -129,6 +224,21 @@ class ChatRequest:
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
同一列在不同行口径分叉"""
# —— 调用方自定义维度(issue #11;追加在末尾,不扰动既有字段的位置构造)——
tenant_id: str | None = None
"""调用方的租户标识,进遥测的 `tenant_id` 真实列(issue #11)。
独立成字段而非混进 `meta`,因为它是唯一享有真实列待遇的维度可挂 RLS
可进复合索引混在 `meta` 里则调用方拼错(`tenantId`)不会报错,只会静默
降级成一个普通维度,正是本 issue 抱怨的失败形态"""
meta: Mapping[str, Any] = field(default_factory=dict)
"""调用方自定义维度的只读快照,库不解释其含义,库内中间件**永不修改**。
**不进缓存 key**: 租户隔离已由 `cache_namespace` 负责并已进 key(ARCH §7.5),
再进一次既重复又会让存量缓存全量冷启动; `meta` 承载的是审计维度而非
语义维度, messages namespace 下换个 batch_id 不应导致 miss"""
@dataclass(frozen=True)
class Usage:
@@ -45,6 +45,8 @@ _EXPECTED_COLUMNS = [
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
]
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
@@ -110,6 +112,9 @@ async def _record_minimal(
"model_reported": None,
"sampling": None,
"reasoning_tokens": None,
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
}
fields.update(overrides)
await recorder.record_llm_call(**fields)
@@ -385,3 +390,241 @@ class TestLeastPrivilegeDeployment:
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]
class _MemoryRecorder:
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
class TestCallerDimensions:
"""调用方自定义维度进遥测(issue #11 Task 4)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_dimensions_reach_telemetry_row(self):
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"})
row = recorder.rows[-1]
assert row["tenant_id"] == "t1"
assert json.loads(row["meta"]) == {"batch": "b-42"}
async def test_default_path_writes_sentinels(self):
"""不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
await client.chat(self._MSG)
row = recorder.rows[-1]
assert row["tenant_id"] == "" and row["meta"] == "{}"
async def test_invalid_meta_key_rejected_before_any_telemetry(self):
"""校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="meta"):
await client.chat(self._MSG, meta={"BAD-KEY": 1})
assert recorder.rows == []
async def test_non_finite_float_rejected_before_any_telemetry(self):
"""nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。"""
recorder = _MemoryRecorder()
async with _client(telemetry=recorder) as client:
with pytest.raises(ValueError, match="nan"):
await client.chat(self._MSG, meta={"k": float("nan")})
assert recorder.rows == []
async def test_meta_does_not_enter_cache_key(self):
"""仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。
带对照组: 只断言"命中"的话,缓存 key 退化成常量(忽略一切输入)时本用例
照样绿那是恒真断言故再改一个**确实进 key** 的维度(namespace)断言
miss,证明 key 仍在区分输入,"meta 不进 key"才是被测出来的结论
"""
cache = InMemoryCache() # 两个 client 共用一份存储,否则对照组的 miss 是白来的
client = _client(cache=cache, cache_namespace="proj", cache_ttl_s=3600)
async with client:
first = await client.chat(self._MSG, meta={"batch": "b-1"})
second = await client.chat(self._MSG, meta={"batch": "b-2"})
assert first.cache_hit is False and second.cache_hit is True
other_ns = _client(cache=cache, cache_namespace="other", cache_ttl_s=3600)
async with other_ns:
assert (await other_ns.chat(self._MSG, meta={"batch": "b-1"})).cache_hit is False
class TestModelFingerprint:
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
+39
View File
@@ -412,6 +412,45 @@ class TestEmbedTelemetry:
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
class TestEmbedCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 embed 链四层透传到每一行遥测。"""
async def test_single_batch_row_carries_dimensions(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"], tenant_id="t1", meta={"batch": "b-42"})
assert rec.rows[0]["tenant_id"] == "t1"
assert rec.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_every_batch_row_carries_the_same_dimensions(self):
"""维度属于本次 `embed()` 调用,不随批次变化。
只断言首行会漏掉"只有第一批带维度"的实现那正是逐层透传最容易漏的形态
"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=1, telemetry=rec)
await client.embed(["a", "b", "c"], tenant_id="t1", meta={"batch": "b-42"})
assert len(rec.rows) == 3 # 切成三批,每批一行
assert [r["tenant_id"] for r in rec.rows] == ["t1", "t1", "t1"]
assert [r["meta"] for r in rec.rows] == ['{"batch": "b-42"}'] * 3
async def test_invalid_meta_rejected_before_any_telemetry(self):
"""校验在切批之前: 遥测层的失败都被降级成 warning,放下游等于没有校验。"""
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
with pytest.raises(ValueError, match="meta"):
await client.embed(["a"], meta={"Bad Key": 1})
assert rec.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
rec = _MemoryRecorder()
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
await client.embed(["a"])
assert rec.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert rec.rows[0]["meta"] == "{}"
@contextlib.contextmanager
def _captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
+53
View File
@@ -480,6 +480,59 @@ class TestTelemetry:
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
class TestOcrCallerDimensions:
"""issue #11: 调用方自定义维度必须沿 OCR 链四层透传到每一行遥测。
OCR 行与 chat 行落在同一张 `llm_calls` : 不覆盖这条链会让同一张表里
一部分行有租户归属一部分永远空白,"先启用后加列则归属无法还原"
"""
async def test_recognize_text_row_carries_dimensions(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert recorder.rows[0]["tenant_id"] == "t1"
assert recorder.rows[0]["meta"] == '{"batch": "b-42"}'
async def test_parse_layout_row_carries_dimensions(self):
"""两个公共方法都是入口: 只测一个会漏掉另一个的透传缺口。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["layout"], telemetry=recorder)
await client.parse_layout(b"jpg", tenant_id="t2", meta={"batch": "b-43"})
assert recorder.rows[0]["tenant_id"] == "t2"
assert recorder.rows[0]["meta"] == '{"batch": "b-43"}'
async def test_failed_attempt_row_also_carries_dimensions(self):
"""失败行同样需要归属: 某租户的请求没被服务,正是审计最需要的一行。"""
recorder = _MemoryRecorder()
client, _, _ = _client(
[_src()],
[TransientError("boom", status_code=500), "text"],
telemetry=recorder,
)
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
assert len(recorder.rows) == 2 # 失败尝试 + 成功尝试
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
with pytest.raises(ValueError, match="meta"):
await getattr(client, method)(b"jpg", meta={"Bad Key": 1})
assert recorder.rows == []
assert client._transport.calls == [] # 连调用都没发出
async def test_defaults_land_as_sentinels(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
await client.recognize_text(b"jpg")
assert recorder.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
assert recorder.rows[0]["meta"] == "{}"
class TestAssembly:
_ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
+25
View File
@@ -118,6 +118,8 @@ class _DummyRecorder:
model_reported,
sampling,
reasoning_tokens,
tenant_id,
meta,
) -> 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:
"""M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。"""
+286
View File
@@ -2,6 +2,7 @@
import asyncio
import json
import os
import sqlite3
import subprocess
from pathlib import Path
@@ -40,6 +41,8 @@ _EXPECTED_COLUMNS = [
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
]
@@ -104,11 +107,38 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
"model_reported": None,
"sampling": None,
"reasoning_tokens": None,
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
"tenant_id": "",
"meta": "{}",
}
fields.update(overrides)
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:
async def test_schema_has_frozen_columns(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
@@ -256,6 +286,135 @@ class TestSQLiteColumnBackfill:
recorder.close()
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
_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
);
"""
_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/探测抛错以模拟权限不足与抖动。
@@ -327,6 +486,8 @@ class TestPostgresBackfillDiscipline:
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
]
def _recorder(self, conn):
@@ -379,6 +540,8 @@ class TestPostgresTableProbe:
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
]
def _recorder(self, conn):
@@ -613,6 +776,129 @@ class TestEmitterSamplingColumn:
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:
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
+129
View File
@@ -398,3 +398,132 @@ class TestSourceConfigExtraBody:
"""
with pytest.raises(TypeError):
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"}