diff --git a/CHANGELOG.md b/CHANGELOG.md index fa62e7c..1fb781d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 未发布 + +每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(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 内。 + ## 1.2.0(2026-08-16) 网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。 diff --git a/README.md b/README.md index ebe35a2..0b9b801 100644 --- a/README.md +++ b/README.md @@ -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 同一治理栈 | @@ -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 列 +) +``` + +四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符且不含首尾空白;`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool` 且字符串值 ≤256 字符。两者**都不进缓存 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 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码: diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index d36896b..f321858 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -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`。 diff --git a/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md b/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md new file mode 100644 index 0000000..2ac54c6 --- /dev/null +++ b/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md @@ -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)。 diff --git a/research-wiki/designs/issue11-caller-dimensions.md b/research-wiki/designs/issue11-caller-dimensions.md new file mode 100644 index 0000000..ed7ad8d --- /dev/null +++ b/research-wiki/designs/issue11-caller-dimensions.md @@ -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 不纳入本次。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 871ad31..9a8d13b 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -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" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index c72c94b..8fc186d 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -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) diff --git a/research-wiki/log.md b/research-wiki/log.md index a83a4a9..6e7d5d7 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -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 篇页面 diff --git a/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md b/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md new file mode 100644 index 0000000..7cdf7c8 --- /dev/null +++ b/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md @@ -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 核心论点的验收) diff --git a/research-wiki/plans/issue11-caller-dimensions.md b/research-wiki/plans/issue11-caller-dimensions.md new file mode 100644 index 0000000..240a0b8 --- /dev/null +++ b/research-wiki/plans/issue11-caller-dimensions.md @@ -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 职责)。 + diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 2520f00..e1413b4 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -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) diff --git a/src/polygateway/embedding.py b/src/polygateway/embedding.py index d7e9b70..18ad334 100644 --- a/src/polygateway/embedding.py +++ b/src/polygateway/embedding.py @@ -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: diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index 1d81e1c..335a495 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -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 diff --git a/src/polygateway/ocr.py b/src/polygateway/ocr.py index 72d3d2a..5a1b1d1 100644 --- a/src/polygateway/ocr.py +++ b/src/polygateway/ocr.py @@ -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""}], 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 diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index c64ccd7..66c80c2 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -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: ... diff --git a/src/polygateway/telemetry/postgres.py b/src/polygateway/telemetry/postgres.py index 24e7986..3f35d96 100644 --- a/src/polygateway/telemetry/postgres.py +++ b/src/polygateway/telemetry/postgres.py @@ -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 = ( diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index 8483c68..4cb5a64 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -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) diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 7a21924..bcb5180 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -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: diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index d1af681..5850c2f 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -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() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index c34a244..6575071 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -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)。""" diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index 8519583..1880f4d 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -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 抓不到。""" diff --git a/tests/unit/test_ocr_client.py b/tests/unit/test_ocr_client.py index 2c0cecb..85e875b 100644 --- a/tests/unit/test_ocr_client.py +++ b/tests/unit/test_ocr_client.py @@ -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", diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index fe722eb..c5009ee 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -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 结构判定。""" diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index c332777..afec6b2 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -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 系统性高估。""" diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index de288c1..5d4325f 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -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"}