feat: expose provider cache tokens and reported model (issue #3)
This commit is contained in:
@@ -54,6 +54,8 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
||||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||||
|
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||||
|
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||||
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
||||||
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
||||||
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.4(2026-07-31)
|
||||||
|
|
||||||
|
响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。
|
||||||
|
|
||||||
|
### 新增(纯增字段,不破坏任何现有调用方)
|
||||||
|
|
||||||
|
- **`LLMResponse` 新增 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`。** 前者是供应商 prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`),后者是 API 响应体里的 `model` 字段(与 `.env` 配的别名可能分叉——供应商把别名指向新权重时,只有它认得出真正跑的那个版本)。两者均带默认值 `None`,逐字段传参的 fake 构造零改动。
|
||||||
|
- **`None` 与 `0` 是两回事,不可混同。** `None` = 该源不上报这个数(下游据此声明「本源不可做缓存成本校正」);`0` = 该源上报了一次真实零命中。网关报文一律不可信:形态异常(负数、字符串、`bool`、`prompt_tokens_details` 非 dict)一律归 `None` 且绝不抛异常——可观测字段缺失不得打断调用。
|
||||||
|
- **遥测表 `llm_calls` 新增 `cached_prompt_tokens` 与 `model_reported` 两列**,`TelemetryRecorder` 端口由 18 字段扩为 20。两个后端在初始化期对**已存在的旧表幂等补列**——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃、遥测静默全失。两侧都是**先探测缺列、只在真缺列时才 ALTER**(SQLite 查 `PRAGMA table_info`,Postgres 查 `pg_attribute`):`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取 ACCESS EXCLUSIVE 锁,而遥测是内联 await,让每个进程的首次写入都去锁共享审计表会拖垮业务调用;稳态下一条 ALTER 都不会发。**补列失败只降级为逐行丢弃,绝不会让 recorder 整体失能**(应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership 检查早于存在性判断,列齐全也会失败)。
|
||||||
|
- **`PricingTable` 支持可选的缓存读取单价 `cached_input_per_1m`。** 配了该档且本次有命中时按 `(prompt - cached) × input + cached × cached_input` 分段计价,消除 cost 的系统性高估;**未配则不猜折扣率**,退化为现状全额输入价(P5 严禁默认值掩盖)。旧价格表文件与 embedding 侧的三参 `cost()` 调用零改动。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **`cache_hit` 与新字段是两个不同的东西。** `cache_hit` 指的始终是 **PolyGateway 自身的响应缓存**(未产生网关调用),而 `cached_prompt_tokens` 指的是**供应商服务器**复用了提示词前缀、那部分按更低单价计费——真实调用里天天发生,`cache_hit` 永远看不见它。字段名保持不变(改名会破坏迁移兼容),语义已在 docstring 中消歧。
|
||||||
|
- **统计供应商缓存命中率必须写 `WHERE cache_hit = false`。** 缓存命中行的这两个字段是**原样回放**的历史值(与 `model`、`prompt_tokens` 同一口径:`CacheMW` 只覆写与本次调用相关的时序字段),计入会重复计数。这与 1.0.3 里 `cost` 缺口口径的坑是同一类。
|
||||||
|
- 缓存命中行的 `cost` 仍恒为 `0.0`(未产生新调用),该短路排在任何单价换算之前,不受缓存单价档影响。
|
||||||
|
- 旧格式的缓存条目(缺这两个键)照常可重建为 `None`,不会回源;历史遥测行的新列为 NULL。
|
||||||
|
|
||||||
## 1.0.3(2026-07-30)
|
## 1.0.3(2026-07-30)
|
||||||
|
|
||||||
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"input_per_1m": 2.1,
|
||||||
|
"output_per_1m": 8.4
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -328,7 +328,18 @@ flowchart TB
|
|||||||
| `cache_hit` | bool | 是否缓存命中 |
|
| `cache_hit` | bool | 是否缓存命中 |
|
||||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||||
|
|
||||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
|
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)。
|
||||||
|
|
||||||
|
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
||||||
|
|
||||||
|
| 字段 | 含义 | 生产者 |
|
||||||
|
|---|---|---|
|
||||||
|
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
|
||||||
|
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
|
||||||
|
|
||||||
|
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
||||||
|
|
||||||
|
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
||||||
|
|
||||||
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||||
|
|
||||||
@@ -449,11 +460,11 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.8 遥测与成本
|
### 7.8 遥测与成本
|
||||||
|
|
||||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**。链路: `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)。
|
**必录字段**(继承三项目 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**(后两者 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||||
|
|
||||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||||
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
||||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。
|
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||||
|
|
||||||
### 7.9 结构化输出阶梯(D14)
|
### 7.9 结构化输出阶梯(D14)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 响应可观测字段扩展设计(Issue #3)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-31
|
||||||
|
- **来源**: Gitea Issue #3(下游 dissect 审计需求)
|
||||||
|
- **状态**: 已批准(2026-07-31,人类逐条确认 A2 / B1 / C1 / D1)
|
||||||
|
- **触发档位**: 强制(变更 `types.py` 公共类型 + `ports.py` 端口签名 + 遥测持久化 schema)
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 目标 1 | `LLMResponse` 暴露供应商侧 prompt cache 命中的输入 token 数 |
|
||||||
|
| 目标 2 | `LLMResponse` 暴露 API 响应体实际返回的模型版本串 |
|
||||||
|
| 目标 3 | 两字段同步落 `llm_calls` 遥测表(端口 18 → 20 字段) |
|
||||||
|
| 目标 4 | `PricingTable` 支持可选的缓存读取单价,消除 cost 高估 |
|
||||||
|
| 非目标 1 | 不改 `EmbeddingResponse` / OCR 响应——embedding 与 OCR 无 prompt cache 语义,且 issue 未提;`cost()` 新增参数带默认值,embedding 调用点(`embedding.py:419`)零改动 |
|
||||||
|
| 非目标 2 | 不改 `cache_hit` 字段名/类型(破兼容),只在 docstring 消歧 |
|
||||||
|
| 非目标 3 | 不为 `reasoning_tokens` 等其他 usage 细项开口(YAGNI,无下游需求) |
|
||||||
|
|
||||||
|
### 1.1 Issue 前提的一处修正
|
||||||
|
|
||||||
|
Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核查结果:
|
||||||
|
|
||||||
|
| 数据 | 实际所在 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| `usage.prompt_tokens_details.cached_tokens` | `raw={"usage": ...}`(流式 `openai_compat.py:362`、非流式 `:444`) | ✅ 已在 raw 内 |
|
||||||
|
| 响应体顶层 `model` | **不在**。非流式 raw 只放 `body["usage"]`;流式 sink 只吸收 `usage` 与 `done` 两键(`_sse_delta`,`:44-47`),chunk 的 `model` 从未收集 | ❌ 需改 transport 采集 |
|
||||||
|
|
||||||
|
故本变更**不是纯字段暴露**,必须同时改 `transports/`。这决定了下面决策 A 的必要性。
|
||||||
|
|
||||||
|
## 2. 决策 A:字段的采集与传递路径
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 raw 约定键 | transport 往 `raw` 里塞 `{"model": ...}`;RetryMW 读 `raw.get("model")` 与 `raw["usage"]["prompt_tokens_details"]["cached_tokens"]` | 改动最小;但 `raw: dict[str, Any]` 变成隐式契约,键名靠约定;且 middleware 要懂 OpenAI 报文嵌套结构 |
|
||||||
|
| A2 TransportResult 强类型字段(**推荐**) | `TransportResult` 追加 `cached_prompt_tokens: int \| None = None`、`model_reported: str \| None = None`;解析逻辑留在 `openai_compat.py`;RetryMW 直接搬运 | 报文格式知识不出 `transports/`,middleware 只做搬运,符合 P7(middleware 只依赖端口、不懂具体报文);两字段带默认值,`monkey_ocr` 的 OCR 结果类型不受影响 |
|
||||||
|
| A3 middleware 解析 raw | RetryMW 内写 OpenAI 嵌套路径解析 | 把 provider 报文格式知识放进 middleware 层,新增非 OpenAI 兼容 transport 时会分叉;违反分层,否决 |
|
||||||
|
|
||||||
|
**选 A2**。`TransportResult` 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 `openai_compat` 之外的构造点零改动;全库该类型仅 2 处构造(`openai_compat.py:354/436`)。
|
||||||
|
|
||||||
|
解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走防御 helper,不抛异常(可观测字段缺失绝不能打断主路径):
|
||||||
|
|
||||||
|
| 输入 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `cached_tokens` 为非负 `int`(**含 `0`**) | 如实保留——`0` 是「该源上报了一次真实零命中」,与「未上报」的 `None` 语义不同,这正是本 issue 的核心诉求 |
|
||||||
|
| `cached_tokens` 为负数 / 非 `int` / `bool` | `None`(`bool` 必须显式排除:`isinstance(True, int)` 在 Python 里为真) |
|
||||||
|
| `usage` 或 `prompt_tokens_details` 非 dict | `None` |
|
||||||
|
| `model` 为非空 `str` | 保留 |
|
||||||
|
| `model` 为非 `str` / 空白串 | `None` |
|
||||||
|
|
||||||
|
## 3. 决策 B:缓存命中回放时两字段取什么值
|
||||||
|
|
||||||
|
| 方案 | LLMResponse 层 | 遥测层 | 权衡 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 原样回放(**推荐**) | 随缓存 JSON 回放原值 | 照记回放值 | 与既有口径一致——`CacheMW._rehydrate`(`cache.py:113-119`)只覆写与本次调用相关的时序字段(`latency_ms`/`ttft_ms`/`max_inter_token_ms`/`call_id`/`cache_hit`),`model`/`provider`/`prompt_tokens` 全部回放。新字段与它们同类(溯源 + 用量),按同一规则处理 |
|
||||||
|
| B2 命中时置 None | 覆写为 None | NULL | 语义上「本次未打供应商,无供应商侧事实」也成立,但与同层的 `prompt_tokens` 回放行为不一致,下游要记两套规则 |
|
||||||
|
| B3 混合 | `model_reported` 回放、`cached_prompt_tokens` 置 None | 同左 | 最难解释,否决 |
|
||||||
|
|
||||||
|
**选 B1**,并写入文档一条度量口径约束(与 `cost` 缺口口径同款教训,ARCHITECTURE §5.1):
|
||||||
|
|
||||||
|
> 统计供应商缓存命中率必须写 `WHERE cache_hit = false`——缓存命中行的 `cached_prompt_tokens` 是历史回放值,计入会重复计数。
|
||||||
|
|
||||||
|
`cost` 不受影响:遥测层 `cache_hit=True` 分支仍短路为 `0.0`,早于任何单价换算。
|
||||||
|
|
||||||
|
## 4. 决策 C:缓存读取单价(人类已选「增加可选档」)
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 ModelPrice 可选第三档(**推荐**) | `cached_input_per_1m: float \| None = None`;`cost()` 增可选参 `cached_prompt_tokens: int \| None = None` | 价格表旧文件零改动仍可加载;`embedding.py:419` 的三参调用零改动 |
|
||||||
|
| C2 cost() 收 LLMResponse | 换算函数直接吃响应对象 | `pricing.py` 会反向依赖 `types.py` 且难以单测纯函数,否决 |
|
||||||
|
|
||||||
|
换算规则与退化路径:
|
||||||
|
|
||||||
|
| 条件 | 计价方式 |
|
||||||
|
|---|---|
|
||||||
|
| 配了 `cached_input_per_1m` 且本次 `cached_prompt_tokens` 为正 | `(prompt - cached) × input + cached × cached_input` |
|
||||||
|
| 未配该档,或本次 `cached_prompt_tokens` 为 None/0 | 全额按 `input` 计(现状行为,不变) |
|
||||||
|
| `cached > prompt`(网关口径异常) | 按 `cached = prompt` 夹取并记一次 warning;不抛异常、不产生负成本 |
|
||||||
|
|
||||||
|
**不猜折扣率**:未配置缓存档时绝不按「五分之一」之类经验值折算(P5 严禁默认值掩盖)。`from_file` 的 fail-loud 校验对新档同样适用:出现该键但非数或为负 → `ValueError`。
|
||||||
|
|
||||||
|
## 5. 决策 D:遥测表扩列的落地方式
|
||||||
|
|
||||||
|
人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败。两侧的失败形态都是**逐行 warning 丢弃**(SQLite `sqlite.py:93`;PG `postgres.py:127`——`_failed` 结构性标志只在 `_ensure_ready` 建池/建表失败时置位,与写入路径无关),即每一次调用的遥测行都丢,却不会有任何一次硬失败提示,与「遥测必录」相悖。
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 初始化期幂等补列(**推荐**) | DDL 加新列;初始化时按需 `ALTER TABLE ADD COLUMN`——PG 用原生 `IF NOT EXISTS`,SQLite 先查 `PRAGMA table_info` 再按需 ALTER | 旧库自动升列,新库无副作用;两处各约 5 行;补列失败沿用现有降级策略(warning,不冒泡) |
|
||||||
|
| D2 只改 DDL,文档写「删表重建」 | 零代码 | 已建表的开发机/下游踩坑后只看到降级 warning,排查成本高;违反防御性 |
|
||||||
|
| D3 引入迁移框架(alembic) | 正规版本化迁移 | 新增依赖,与「依赖极简」铁律冲突,规模严重不匹配,否决 |
|
||||||
|
|
||||||
|
**选 D1**。列类型:SQLite `cached_prompt_tokens INTEGER` / `model_reported TEXT`;PG `INTEGER` / `TEXT`。两列均可空(NULL = 该源未上报),不设 NOT NULL 与默认值——0 与 NULL 的区分正是本 issue 的核心诉求。
|
||||||
|
|
||||||
|
**D1 的实现纪律(必须钉进计划,否则补列会把降级放大成永久失能)**:
|
||||||
|
|
||||||
|
| 约束 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| SQLite 的 ALTER 必须用**独立 try**,且置于 `self._conn = conn` **之后** | `__init__` 现有 try 的最后一句才是 `self._conn = conn`(`sqlite.py:74-84`);ALTER 抛异常会让 `_conn` 停在 `None`,`record_llm_call` 首行即 return —— 整个 recorder 永久 no-op,比逐行丢弃严重得多 |
|
||||||
|
| `duplicate column name` 视为成功吞掉 | `PRAGMA table_info` 探测 + ALTER 是 TOCTOU:多 worker 共用同一 db 文件时后到者必然撞上 |
|
||||||
|
| 不得为补列加宽 `except` | `sqlite.py:93` 只捕 `(OSError, sqlite3.Error)`,取消是天然穿透的;PG 侧的 `except asyncio.CancelledError: raise` 必须留在最前 |
|
||||||
|
| PG 用原生 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 无 TOCTOU;落在既有 `_init_lock` 保护的 `_ensure_ready` 内 |
|
||||||
|
|
||||||
|
端口 `TelemetryRecorder.record_llm_call` 由 18 字段扩为 20 字段(关键字参数),`ports.py:248` 的「18 字段冻结」注释与 ARCHITECTURE 相应表述同步更新。新增参数在 Protocol 上**不设默认值**——依据不是「漏改会报错」(本仓无 mypy,`make lint` 只有 ruff + import-linter,8 个测试 fake 全是 `**fields`,漏改根本不会自动红),而是**库外不存在第三方实现者**:三项目迁移文档明确删除各自的 TelemetryRecorder Protocol 与实现(`migrations/govdoc-saas.md:36`、`video-tree-trm5.md:36/51`),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。
|
||||||
|
|
||||||
|
## 6. 行为审计(既有行为逐条标注)
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
|---|---|
|
||||||
|
| `LLMResponse` 前 11 字段顺序即公共承诺 | **保留**,新字段追加到尾部(`structured_data` 之后) |
|
||||||
|
| 缓存序列化 `_serialize` 用 `asdict` 后 pop 掉 `structured_data`、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) |
|
||||||
|
| `cache_hit` 语义 = PolyGateway 自身响应缓存 | **保留**,仅补 docstring 消歧 |
|
||||||
|
| `TelemetryEmitter` 单一 `_record` helper(遥测必录铁律:禁止复制参数列表) | **保留**,新字段只在 `_record` 增两个参数,三个 `emit_*` 入口各传一次 |
|
||||||
|
| 失败尝试 / 终态失败行记 `usage_source="unavailable"` | **保留**,两个新字段在这些路径记 `None` |
|
||||||
|
| `pricing.cost()` 是唯一换算点(注释语)| **修正**:实际有 `TelemetryEmitter` 与 `embedding.py:419` 两个调用点,顺带订正该 docstring(限于一行注释,不做结构重构) |
|
||||||
|
| OCR / embedding 各自构造 `LLMResponse` | **保留**,两字段取默认 `None`(该路径无供应商 cache 概念) |
|
||||||
|
|
||||||
|
## 7. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER;SQLite 的 `__init__` **不持** `_lock`(它只保护 `_write`/`close`),跨进程共库靠上面 D1 纪律里的「duplicate column 视为成功」兜底。取消穿透不变:PG 两处 `except asyncio.CancelledError: raise` 保持在最前,SQLite 侧只捕 `(OSError, sqlite3.Error)` 故天然穿透 |
|
||||||
|
| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有逐行丢弃,绝不冒泡到调用方,也绝不让 recorder 整体失能(见 D1 纪律)。解析失败 → 字段记 `None`,不影响响应返回 |
|
||||||
|
| 幂等与重复 | 补列幂等(PG `IF NOT EXISTS`;SQLite 先探测)。写入幂等性不变(`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` 按 `call_id`) |
|
||||||
|
| 持久化与原子性 | 单行 INSERT 原子性不变;新增两列不参与主键与冲突判定。缓存 JSON 是整值覆写,无部分写入 |
|
||||||
|
| 向后兼容 | 下游三项目 + dissect:纯增字段带默认值,逐字段传参的 fake 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 |
|
||||||
|
|
||||||
|
## 8. 错误处理与测试策略
|
||||||
|
|
||||||
|
错误分类:本变更**不新增任何错误路径**。网关报文里这两项缺失或类型异常 → 记 `None`,不归入四分类(它们不是失败,是「该源没给」)。价格表配置错误仍走装配期 `ValueError`(fail-loud,不属运行时四分类)。
|
||||||
|
|
||||||
|
| 层 | 测试(先失败后通过) |
|
||||||
|
|---|---|
|
||||||
|
| types(unit) | 新字段默认值为 `None`;字段顺序不变(前 11 位置构造仍成立) |
|
||||||
|
| transports(unit) | 用真实网关响应二次构造样本:① 流式含 `prompt_tokens_details.cached_tokens` → 解析出正整数;② 非流式同上;③ 无该键 → `None`;④ 值为 `"abc"`/负数 → `None` 不抛;⑤ 流式 chunk 的 `model` 被 sink 采集;⑥ 顶层无 `model` → `None` |
|
||||||
|
| retry(unit) | `_build_response` 透传两字段;失败尝试路径不受影响 |
|
||||||
|
| cache(unit) | ① 新字段随序列化往返;② **旧格式**缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1 |
|
||||||
|
| pricing(unit) | ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ `cached > prompt` → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → `ValueError` |
|
||||||
|
| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 且 recorder 仍能工作(SQLite `_conn` 不得因此为 None) |
|
||||||
|
| 契约(**新增,不可省**) | 断言 `TelemetryEmitter` 传给 recorder 的实参键集合 == 两个后端的 `_COLUMNS`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端的 try **之外**(`sqlite.py:90` / `postgres.py:121`),emitter 漏传新字段会抛 `KeyError`,被 `_record` 的 `except Exception` 吞成 warning → **静默丢遥测**。这是本变更最危险的失败形态,而现有 8 个 `**fields` 形态的 fake 一个都拦不住 |
|
||||||
|
|
||||||
|
> integration 层的 Redis/PG 测试遵守既有纪律:共享后端严禁并跑,`conda run -n PolyGateway --no-capture-output`。
|
||||||
|
|
||||||
|
## 9. 影响面清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `src/polygateway/types.py` | `LLMResponse` +2 字段;`TransportResult` +2 字段;`cache_hit` docstring 消歧 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | sink 采集 `model`;两处 `TransportResult` 构造填新字段;新增防御解析 helper |
|
||||||
|
| `src/polygateway/middleware/retry.py` | `_build_response` 透传 2 字段 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | `_record` + 三个 `emit_*` 各透传 2 字段;cost 换算传入 `cached_prompt_tokens` |
|
||||||
|
| `src/polygateway/pricing.py` | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验;订正唯一换算点注释 |
|
||||||
|
| `src/polygateway/ports.py` | `TelemetryRecorder` 18 → 20 字段 |
|
||||||
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||||
|
| `tests/` | 四处天然拦截点必须同步(漏改即红): 两个 `_record_minimal` 手写 18 键 dict(`unit/test_telemetry.py:76` 起、`integration/test_postgres_telemetry.py:81-105`)与两个 `_EXPECTED_COLUMNS` 列序断言(`unit/test_telemetry.py:18-40`、`integration/test_postgres_telemetry.py:22-41`);`unit/test_ports.py:96` 的全签名 fake 同步(它**不会**红,Protocol 的 isinstance 不校验签名);新增契约测试 |
|
||||||
|
| `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 |
|
||||||
|
| 「18 字段冻结」的其余措辞点 | `ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1` |
|
||||||
|
| Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) |
|
||||||
|
| `.env.example:56` | 该行内联注释是仓内**唯一**的价格表格式说明(无独立模板文件,`config/prices.json` 是未入库的本地文件),补 `cached_input_per_1m` 可选档 |
|
||||||
|
|
||||||
|
## 10. 审批记录
|
||||||
|
|
||||||
|
2026-07-31 人类逐条确认: **A2**(TransportResult 强类型字段)、**B1**(缓存命中原样回放 + 度量口径带 `cache_hit = false`)、**C1**(ModelPrice 可选缓存单价档)、**D1**(DDL 加列 + 初始化期幂等补列)。设计获批,进入 `writing-plans`。
|
||||||
|
|
||||||
|
版本号按 `1.1.0` 推进(纯增字段不破坏下游,但触及端口签名与表结构,minor 位比 patch 位更能提示下游);发版前若人类另有指示以指示为准。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:response-observability-fields
|
||||||
|
title: "响应可观测字段扩展(Issue #3)"
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 响应可观测字段扩展(Issue #3)
|
||||||
|
|
||||||
|
全文见 `2026-07-31-response-observability-fields-design.md`。来源: Gitea Issue #3(下游 dissect 的调用审计需求)。
|
||||||
|
|
||||||
|
## 选定方案
|
||||||
|
|
||||||
|
| 决策 | 选定 | 关键理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| A 采集路径 | `TransportResult` 追加 `cached_prompt_tokens` / `model_reported` 强类型字段,解析留在 `openai_compat.py` | OpenAI 报文格式知识不出 `transports/`,middleware 只做搬运(P7) |
|
||||||
|
| B 缓存命中语义 | 原样回放;度量口径必须带 `cache_hit = false` | 与 `_rehydrate` 既有口径一致——它只覆写时序字段,`model`/`prompt_tokens` 全回放 |
|
||||||
|
| C 缓存单价 | `ModelPrice` 加可选 `cached_input_per_1m`,`cost()` 加可选参 | 旧价格表与 `embedding.py:419` 三参调用零改动;未配置该档时**不猜折扣率**,退化为全额计价 |
|
||||||
|
| D 遥测扩列 | 端口 18 → 20 字段;DDL 加列 + 初始化期幂等补列 | `CREATE TABLE IF NOT EXISTS` 不会给旧库补列,INSERT 会**逐行 warning 丢弃**——遥测全失却无硬失败提示 |
|
||||||
|
|
||||||
|
## 被否决的备选
|
||||||
|
|
||||||
|
| 备选 | 否决原因 |
|
||||||
|
|---|---|
|
||||||
|
| A1 往 `raw` 里塞约定键 | `dict[str, Any]` 沦为隐式契约,且 middleware 要懂 OpenAI 嵌套结构 |
|
||||||
|
| A3 middleware 内解析 raw | 报文格式知识进 middleware,新增非 OpenAI 兼容 transport 时会分叉,违反分层 |
|
||||||
|
| B2 命中时置 None / B3 混合 | 与同层 `prompt_tokens` 的回放行为不一致,下游要记两套规则 |
|
||||||
|
| C2 `cost()` 直接收 `LLMResponse` | `pricing.py` 会反向依赖 `types.py`,且纯函数难单测 |
|
||||||
|
| D2 只改 DDL、文档写「删表重建」 | 已建表的开发机/下游只会看到降级 warning,排查成本高 |
|
||||||
|
| D3 引入 alembic 迁移框架 | 新增依赖违反「依赖极简」铁律,规模严重不匹配 |
|
||||||
|
|
||||||
|
## 独立审查修正(2026-07-31)
|
||||||
|
|
||||||
|
Codex CLI 安装损坏(vendor 二进制缺失),改由全新上下文的 Claude subagent 审。三条问题全部核实属实并已折回设计:
|
||||||
|
|
||||||
|
1. PG 缺列时**不是**结构性短路,而是逐行 warning(`_failed` 仅在 `_ensure_ready` 置位)。
|
||||||
|
2. SQLite 补列若塞进 `__init__` 现有 try,异常会让 `_conn` 停在 `None` → recorder 永久 no-op。已定纪律: 独立 try、置于 `self._conn = conn` 之后、duplicate column 视为成功。
|
||||||
|
3. 「端口无默认值 → 漏改即报错」不成立(无 mypy,8 个 fake 全是 `**fields`)。改为新增「emitter 实参键集合 == `_COLUMNS`」契约测试兜底——否则 `KeyError` 会被 `_record` 的 `except Exception` 吞成 warning,静默丢遥测。
|
||||||
|
|
||||||
|
相关: [[m1-core-design]]、[[est-tokens-decoupling]]
|
||||||
@@ -110,6 +110,16 @@
|
|||||||
"id": "plan:est-tokens-decoupling",
|
"id": "plan:est-tokens-decoupling",
|
||||||
"label": "est_tokens 解耦实施计划",
|
"label": "est_tokens 解耦实施计划",
|
||||||
"type": "plan"
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:response-observability-fields",
|
||||||
|
"label": "响应可观测字段扩展(Issue #3)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:response-observability-fields",
|
||||||
|
"label": "响应可观测字段扩展实现计划",
|
||||||
|
"type": "plan"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -189,6 +199,13 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
||||||
"added": "2026-07-30T09:39:26.442986+00:00"
|
"added": "2026-07-30T09:39:26.442986+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:response-observability-fields",
|
||||||
|
"target": "design:response-observability-fields",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策",
|
||||||
|
"added": "2026-07-31T11:10:03.872049+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-07-30 09:39 UTC
|
> 自动生成,更新时间:2026-07-31 12:25 UTC
|
||||||
|
|
||||||
## design (16)
|
## design (18)
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
||||||
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
|
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
|
||||||
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
|
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
|
||||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||||
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
|
|
||||||
## finding (11)
|
## finding (11)
|
||||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||||
@@ -33,22 +35,24 @@
|
|||||||
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
- [P6 混合浸泡首跑基线与记分板三重伪击穿修复](findings/p6-soak-baseline.md) `finding:p6-soak-baseline`
|
||||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||||
|
|
||||||
## plan (12)
|
## plan (14)
|
||||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||||
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
|
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
|
||||||
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
|
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
|
||||||
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
||||||
|
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||||
|
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
- [表结构: llm_calls(遥测 18 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
- [表结构: llm_calls(遥测 20 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||||
|
|
||||||
## metric (2)
|
## metric (2)
|
||||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||||
|
|||||||
@@ -57,3 +57,10 @@
|
|||||||
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
|
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
|
||||||
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
|
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
|
||||||
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
|
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
|
||||||
|
- [2026-07-31 08:35 UTC] 新增 design: 响应可观测字段扩展(Issue #3) (design:response-observability-fields)
|
||||||
|
- [2026-07-31 08:37 UTC] 重建索引: 44 篇页面
|
||||||
|
- [2026-07-31 11:10 UTC] 新增 plan: 响应可观测字段扩展实现计划 (plan:response-observability-fields)
|
||||||
|
- [2026-07-31 11:10 UTC] 新增边: plan:response-observability-fields --implements--> design:response-observability-fields
|
||||||
|
- [2026-07-31 11:10 UTC] 重建索引: 46 篇页面
|
||||||
|
- [2026-07-31 11:11 UTC] 重建索引: 46 篇页面
|
||||||
|
- [2026-07-31 12:25 UTC] 重建索引: 46 篇页面
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 实现计划: 响应可观测字段扩展(Issue #3)
|
||||||
|
|
||||||
|
- **目标**: 让 `LLMResponse` 与遥测表如实暴露「供应商 prompt cache 命中的输入 token 数」与「API 实际返回的模型版本串」。
|
||||||
|
- **方案概述**: 报文解析留在 `transports/`(新增两个强类型字段随 `TransportResult` 上浮),`RetryMW` 只搬运;遥测端口由 18 字段扩到 20 并给两个后端加幂等补列;`PricingTable` 增加可选缓存单价档消除 cost 高估。缓存命中行按既有口径原样回放。
|
||||||
|
- **依据设计**: `research-wiki/designs/2026-07-31-response-observability-fields-design.md`(2026-07-31 已获人类批准,决策 A2/B1/C1/D1)。
|
||||||
|
- **涉及技术**: Python 3.11 frozen dataclass、httpx SSE 解析、sqlite3、asyncpg、pytest。
|
||||||
|
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移,保真校验不适用。但遥测后端属 ARCHITECTURE §1.4 资产,T5 明确约束「不得改变既有降级语义」。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 | 本次改动 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 冻结公共类型 | `LLMResponse` / `TransportResult` 各 +2 字段;`cache_hit` docstring 消歧 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | OpenAI 兼容报文解析 | 防御解析 helper;SSE sink 采集 `model`;两处 `TransportResult` 构造填新字段 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 尝试循环 | `_build_response` 搬运两字段 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 响应缓存 | **零代码改动**(自动透传),仅补测试固化行为 |
|
||||||
|
| `src/polygateway/pricing.py` | 单价换算 | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验 |
|
||||||
|
| `src/polygateway/ports.py` | 端口契约 | `TelemetryRecorder` 18 → 20 字段 |
|
||||||
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 遥测后端 | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 遥测唯一调用点 | `_record` 与三个 `emit_*` 搬运两字段;cost 换算传入缓存 token |
|
||||||
|
|
||||||
|
字段定义(全库唯一权威,后续任务一律引用此处):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# LLMResponse 与 TransportResult 尾部,同名同类型同默认值
|
||||||
|
cached_prompt_tokens: int | None = None # 供应商 prompt cache 命中的输入 token;None = 该源未上报
|
||||||
|
model_reported: str | None = None # API 响应体的 model 字段;None = 未上报
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T1. 类型层加字段
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/types.py`
|
||||||
|
|
||||||
|
**行为**: 在 `LLMResponse` 尾部(`structured_data` 之后)与 `TransportResult` 尾部(`raw` 之后)各追加上面两个字段。`cache_hit` 的语义在 `LLMResponse` docstring 中写明是「**PolyGateway 自身响应缓存**命中,与供应商 prompt cache 无关,后者见 `cached_prompt_tokens`」。
|
||||||
|
|
||||||
|
**验收**: 前 11 个字段的顺序与名字一字不动;新字段有默认值,`LLMResponse(...)` 按前 11 位置参数构造仍成立;`TransportResult` 现有两处构造(`openai_compat.py:354/436`)不传新字段也能构造。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_types.py`): ① 不传新字段时两个类型的新字段均为 `None`;② 按位置构造 `LLMResponse` 的前 11 字段仍可用(迁移兼容承诺)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_types.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: add cached prompt tokens and reported model to response types`
|
||||||
|
|
||||||
|
## T2. transport 采集与防御解析
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/transports/openai_compat.py`
|
||||||
|
|
||||||
|
**行为**分三处:
|
||||||
|
|
||||||
|
1. 新增两个模块级防御 helper(网关返回一律不可信,解析失败**返回 None,不抛异常**):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _coerce_cached_tokens(usage: Any) -> int | None:
|
||||||
|
"""从 usage.prompt_tokens_details.cached_tokens 取非负整数;任何形态异常 → None。"""
|
||||||
|
|
||||||
|
def _coerce_model_reported(value: Any) -> str | None:
|
||||||
|
"""响应体 model 字段: 非空 str 才收,其余(含空串/非 str)→ None。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`_coerce_cached_tokens` 需容忍:`usage` 为 None、`prompt_tokens_details` 缺失或非 dict、`cached_tokens` 为 `bool`/`str`/负数/浮点。`bool` 必须排除(Python 中 `isinstance(True, int)` 为真)。**`0` 必须如实保留而非归 None**——真实零命中与未上报是两回事,这是 issue 的核心诉求。
|
||||||
|
|
||||||
|
2. 流式路径:`_sse_delta`(`:44-47`)当前只把 `usage` 旁路进 sink。补一条——chunk 里出现 `model` 时写 `usage_sink["model"]`(**首次写入即固定**,后续 chunk 不覆盖,避免末帧异常值污染)。`_stream_once` 的 `TransportResult` 构造(`:354`)填 `cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage"))`、`model_reported=_coerce_model_reported(sink.get("model"))`。
|
||||||
|
|
||||||
|
3. 非流式路径:`TransportResult` 构造(`:436`)填 `_coerce_cached_tokens(body.get("usage"))` 与 `_coerce_model_reported(body.get("model"))`。
|
||||||
|
|
||||||
|
**验收**: `raw` 的内容保持原样不动(新字段是独立格子,不是杂物袋的扩充);OCR 与 embedding 的解析路径一行不改。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_openai_compat.py`,用现有 fake 响应二次构造):
|
||||||
|
|
||||||
|
| 用例 | 期望 |
|
||||||
|
|---|---|
|
||||||
|
| 非流式 usage 含 `prompt_tokens_details.cached_tokens: 128` | `cached_prompt_tokens == 128` |
|
||||||
|
| 流式 usage 帧同上 | 同上 |
|
||||||
|
| 无 `prompt_tokens_details` / usage 帧缺失 | `None` |
|
||||||
|
| `cached_tokens` 为 `"abc"` / `-1` / `True` / `1.5` / `[]` / dict | `None`,且**不抛异常** |
|
||||||
|
| `cached_tokens` 为 `0` | `0`(真实零命中,**不得**归 None) |
|
||||||
|
| `prompt_tokens_details` 非 dict | `None` |
|
||||||
|
| 非流式 body 含 `model: "MiniMax-Text-01-250321"` | `model_reported` 为该串 |
|
||||||
|
| 流式首个含 model 的 chunk 后又出现不同 model | 取**首个** |
|
||||||
|
| body 无 `model` / `model` 为 `""` | `None` |
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_openai_compat.py -v` → PASS(新增用例先失败后通过)。
|
||||||
|
|
||||||
|
**提交**: `feat: collect provider cache tokens and reported model in transport`
|
||||||
|
|
||||||
|
## T3. RetryMW 搬运与缓存回放固化
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/middleware/retry.py`
|
||||||
|
|
||||||
|
**行为**: `_build_response`(`:419-438`)追加 `cached_prompt_tokens=result.cached_prompt_tokens`、`model_reported=result.model_reported`。`model=source.model` **保持不变**——别名仍是主字段,真实版本是旁证(设计非目标 2)。
|
||||||
|
|
||||||
|
`middleware/cache.py` **不改一行**:`_RESPONSE_FIELDS` 由 `dataclasses.fields(LLMResponse)` 动态生成(`:26`)、`_serialize` 用 `asdict`(`:139`),新字段自动进出;决策 B1 要求命中时原样回放,而 `_rehydrate` 的覆写清单(`:113-119`)本就不含新字段,零改动即是正确行为。本任务用测试把它钉死。
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- `tests/unit/test_retry.py`: transport 返回带两字段的 `TransportResult` → `chat()` 返回的 `LLMResponse` 上两字段一致;transport 未上报时为 `None`。
|
||||||
|
- `tests/unit/test_cache.py`: ① 带两字段的响应写入缓存再命中,回放值与原值相等且 `cache_hit=True`;② **旧格式兼容**——手工构造缺这两个键的缓存 JSON 塞进后端,命中后能正常 rehydrate 且两字段为 `None`(不得抛异常回源)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_retry.py tests/unit/test_cache.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: carry the new observability fields through retry and cache`
|
||||||
|
|
||||||
|
## T4. 缓存读取单价
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/pricing.py`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ModelPrice: # 追加第三档,可选
|
||||||
|
cached_input_per_1m: float | None = None
|
||||||
|
|
||||||
|
def cost(self, model: str, prompt_tokens: int, completion_tokens: int,
|
||||||
|
cached_prompt_tokens: int | None = None) -> float | None:
|
||||||
|
```
|
||||||
|
|
||||||
|
换算规则(设计 §4):配了缓存档**且** `cached_prompt_tokens` 为正 → `(prompt - cached) × input + cached × cached_input`;否则全额按 `input`(现状,逐位不变)。`cached > prompt` 时按 `prompt` 夹取并 `logger.warning` 一次(沿用 `_warned` 的去重思路,按 model 去重,防日志风暴),**绝不产生负成本**。
|
||||||
|
|
||||||
|
`from_file` 的 fail-loud 扩展:条目出现 `cached_input_per_1m` 键时必须可转 float 且非负,否则 `ValueError`;不出现该键 = 合法(旧价格表零改动)。`__post_init__` 同步校验非负。
|
||||||
|
|
||||||
|
顺带订正 `PricingTable` docstring(`pricing.py:36`)那句「cost() 是全库唯一换算点(经 TelemetryEmitter)」——实际有 `TelemetryEmitter`(`middleware/telemetry.py:137`)与 `embedding.py:419` 两个调用点(设计 §6 行为审计已声明)。**只改这一行注释,不做任何结构重构**。
|
||||||
|
|
||||||
|
**验收**: `embedding.py:419` 的三参调用形态**一行不改**仍可用;未配缓存档时,任意输入下 `cost()` 结果与改前逐位相等。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_pricing.py`): ① 配缓存档 + 命中 → 成本严格低于全额且等于手算值;② 未配缓存档 + 命中 → 与不传该参数结果相等;③ `cached > prompt` → 结果等于全部按缓存价、非负、有 warning;④ `cached_prompt_tokens=None/0` → 全额;⑤ 三参旧调用签名可用;⑥ 价格表含 `cached_input_per_1m: -1` 或 `"x"` → `from_file` 抛 `ValueError`;⑦ 无该键的旧价格表照常加载。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_pricing.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: support a cached input price tier in the pricing table`
|
||||||
|
|
||||||
|
## T5. 端口扩字段与后端补列
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `TelemetryRecorder.record_llm_call`(`ports.py:250-271`)在 `cost` 之后追加 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`,**不设默认值**(设计 §5:库外无第三方实现者)。同步该 Protocol 的「18 字段冻结」docstring。
|
||||||
|
|
||||||
|
2. 两个后端的 `_DDL` 加列(SQLite `INTEGER`/`TEXT`;PG `INTEGER`/`TEXT`,均可空、无默认值)。**新列在 DDL 里必须放在 `created_at` 之后(即表的最末尾),不得插在 `cost` 之后**——旧表走 `ALTER TABLE ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前面,两条路径的物理列序就会分叉,而 `tests/integration/test_postgres_telemetry.py:117-128` 的 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该表是与真实批跑共享的表、**严禁 DROP/TRUNCATE**(文件头隔离纪律),分叉后没有合规修法。
|
||||||
|
|
||||||
|
`_COLUMNS` 在 `"cost"` 之后追加同名两项即可——`_INSERT` 是显式列名拼装(`sqlite.py:62-65`/`postgres.py:67-71`),`_COLUMNS` 只需与自身的 `row = tuple(...)` 自洽,**与 DDL 物理列序无关**。
|
||||||
|
|
||||||
|
3. **幂等补列**,按设计 D1 纪律执行:
|
||||||
|
|
||||||
|
- **SQLite**(`sqlite.py:74-84`):补列代码必须放在 `self._conn = conn` **之后**、用**独立 try**,且**首行必须守卫 `if self._conn is None: return`**——初始化 try 吞掉失败时 `self._conn` 仍是 `None`(局部 `conn` 甚至未绑定),无守卫的补列块会抛 `AttributeError`/`NameError`,这两者不被 `sqlite3.Error` 捕获,会直接逃出 `__init__`,打破「初始化失败静默降级」的对外契约(既有测试 `tests/unit/test_telemetry.py:132-135` `test_unwritable_path_degrades_silently` 会红)。守卫之后:`PRAGMA table_info(llm_calls)` 取现有列名集合,缺哪列补哪列;捕获 `sqlite3.Error` 时消息含 `duplicate column` 视为成功(多进程共库的 TOCTOU),其余记 warning。**绝不允许**因补列失败把 `self._conn` 置回 `None`——那会让整个 recorder 永久 no-op。
|
||||||
|
- **Postgres**(`postgres.py:_ensure_ready` 内、`_DDL` 执行之后):两条 `ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS ...`,共享既有 `_init_lock` 与 `except asyncio.CancelledError: raise` 结构。
|
||||||
|
|
||||||
|
**验收(降级语义不得改变)**: SQLite 侧 `except` 不得加宽(取消天然穿透);PG 侧 `CancelledError` 分支保持在最前;写入失败仍是逐行 warning 丢弃,不冒泡。
|
||||||
|
|
||||||
|
**必须同步改的测试(共 5 处)**:
|
||||||
|
|
||||||
|
| 位置 | 内容 | 漏改会怎样 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/unit/test_telemetry.py:76` 起 `_record_minimal` | 手写 18 键 dict | **红**(`KeyError`) |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:81-105` `_record_minimal` | 同上 | **红** |
|
||||||
|
| `tests/unit/test_telemetry.py:18-40` `_EXPECTED_COLUMNS` | 19 项列序断言(含 `created_at`) | **红**;新列追加到 `created_at` **之后** |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:22-41` `_EXPECTED_COLUMNS` | 同上 | **红**;同上 |
|
||||||
|
| `tests/unit/test_ports.py:96` `_DummyRecorder` | 唯一写全签名的 fake | **不会红**(它只被 `:131` 的 `isinstance` 使用,`runtime_checkable` Protocol 只校验方法名不校验签名),但仍应同步以免误导后来者 |
|
||||||
|
|
||||||
|
前四处是本次仅有的天然拦截点;端口加参数**不会**带来编译期保护(本仓无 mypy,其余 8 个 fake 全是 `**fields`)。
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- `tests/unit/test_telemetry.py`(SQLite):① 20 字段写入后可读回两个新列的值(含 `None`);② **旧表升级**——先用 18 列 DDL 手工建表,再实例化 `SQLiteRecorder`,写入成功且新列有值;③ **补列失败路径**(设计 §8 第 ③ 条,最危险的分支,不可用成功路径顶替)——构造一个 ALTER 必然失败的场景(把 `llm_calls` 建成同名 view,或注入在 ALTER 上抛 `sqlite3.OperationalError` 的连接),断言构造**不抛异常**、`recorder._conn` 仍非 `None`、后续 `record_llm_call` 不抛(降级为逐行 warning);④ 初始化路径不可写时仍静默降级(`test_unwritable_path_degrades_silently` 保持绿)。
|
||||||
|
- `tests/integration/test_postgres_telemetry.py`:① 20 字段写入 PG 并 `SELECT` 回读;② 18 列旧表经初始化后自动补列并写入成功。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py tests/unit/test_ports.py -v` → PASS;PG 部分 `conda run -n PolyGateway --no-capture-output pytest tests/integration/test_postgres_telemetry.py -v` → PASS。**PG/Redis 属共享后端,严禁与其他会话或钩子测试并跑**,起跑前确认无并发占用。
|
||||||
|
|
||||||
|
**提交**: **与 T6 合并为一次提交**,不得单独落地。理由:T5 落地后 emitter 仍只传 18 键,后端的 `row = tuple(fields[col] for col in _COLUMNS)` 会抛 `KeyError`,被 `_record` 的 `except Exception`(`middleware/telemetry.py:164-165`)吞成 warning → **该 commit 处于全量遥测静默丢失的状态**,且现有测试无一能捕获。提交信息见 T6。
|
||||||
|
|
||||||
|
## T6. Emitter 搬运与契约测试(与 T5 同一次提交)
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/middleware/telemetry.py`
|
||||||
|
|
||||||
|
**行为**: `_record`(`:108-125`)新增两个参数并透传给 `record_llm_call`;三个入口各自提供取值——
|
||||||
|
|
||||||
|
| 入口 | `cached_prompt_tokens` | `model_reported` |
|
||||||
|
|---|---|---|
|
||||||
|
| `emit_attempt` | `response.cached_prompt_tokens if response else None` | 同左 |
|
||||||
|
| `emit_cache_hit` | `response.cached_prompt_tokens`(B1 原样回放) | 同左 |
|
||||||
|
| `emit_terminal_failure` | `None` | `None` |
|
||||||
|
|
||||||
|
cost 换算(`:137`)改为把 `cached_prompt_tokens` 传进 `self._pricing.cost(...)`。`cache_hit → 0.0` 与 `usage_source == "unavailable" → None` 两条短路的**先后顺序一字不动**(ARCHITECTURE §5.1 cost 口径不变式)。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_telemetry.py`):
|
||||||
|
|
||||||
|
- **契约测试(不可省)**: 用记录 kwargs 的 fake recorder 跑一次 `emit_attempt`,断言 `set(kwargs) == set(sqlite._COLUMNS) == set(postgres._COLUMNS)`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端 try 之外(`sqlite.py:90`/`postgres.py:121`),emitter 漏传字段会抛 `KeyError` 并被 `_record` 的 `except Exception` 吞成 warning → 静默丢遥测;现有 8 个 `**fields` 形态的 fake 一个都拦不住。
|
||||||
|
- 三个入口各记一行,断言新字段取值符合上表。
|
||||||
|
- cost 回归:配了缓存档且响应带 `cached_prompt_tokens` → 落库 cost 低于全额;缓存命中行 cost 仍为 `0.0`;`unavailable` 行仍为 `None`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py -v` → PASS;随后 `make ci` 全绿(含 ruff 与 import-linter)。
|
||||||
|
|
||||||
|
**提交**(含 T5 全部改动): `feat: record the observability fields end to end through telemetry`
|
||||||
|
|
||||||
|
## T7. 文档同步与发版
|
||||||
|
|
||||||
|
- [ ] **改**: `research-wiki/ARCHITECTURE.md`、`CHANGELOG.md`、`.env.example`、`pyproject.toml`、`src/polygateway/__init__.py`、四处「18 字段冻结」措辞点、Gitea Wiki 站
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `ARCHITECTURE.md`:§5.1 新增字段表补两行;**§7.8「必录字段」的行内清单**(`:452`)补两项——该文件不含字面「18 字段冻结」,§7.8 与 D8(`:202`)才是遥测字段的落点;§7.8 末条「`pricing.py` 维护 model →(input 单价, output 单价)表」同步第三档。补一条度量口径警示(与 cost 缺口同款):**统计供应商缓存命中率必须带 `WHERE cache_hit = false`**,否则缓存回放行会被重复计入。
|
||||||
|
2. 代码里的「18 字段/18 列」措辞共 **6 处**,全部订正(`grep -rn "18 字段\|18 列" src/ tests/` 可复核):`ports.py:248`、`middleware/telemetry.py:31`、`pricing.py:6`、`telemetry/sqlite.py:87`、`telemetry/postgres.py:9`(「18 列 schema 与 SQLite 版同名同序」)、`tests/unit/test_telemetry.py:1`。
|
||||||
|
3. `.env.example:56` 是仓内**唯一**的价格表格式说明(无独立模板文件),补 `cached_input_per_1m` 可选档与「不填即全额计价、库不猜折扣率」的说明。
|
||||||
|
4. 版本 bump `1.0.3` → `1.1.0`,**两处必须同步**(`pyproject.toml:7` 与 `src/polygateway/__init__.py:34`;`tests/unit/test_package.py:11` 会断言二者相等)。
|
||||||
|
5. `CHANGELOG.md` 顶部新增 `## 1.1.0` 段,沿用既有写法(先讲问题、再讲变更、点明下游要读什么):两个新字段的语义与 `None`/`0` 之别、`cache_hit` 与供应商 prompt cache 的区分、遥测表新增两列与自动补列、价格表可选缓存档、度量口径的 `cache_hit = false` 约束。
|
||||||
|
6. Gitea Wiki 站(需单独 `git clone https://gitea.iomgaa.online/iomgaa/PolyGateway.wiki.git`)按 `docs-convention.md` §2 清单同步:`参考-公共API`(LLMResponse 字段表)、`参考-配置键`(价格表格式)、`指南-遥测与成本`(新列与成本校正口径)、`Home.md` 版本号与安装命令、`_Sidebar.md` 如有结构变化。
|
||||||
|
|
||||||
|
**验收**: 版本 bump 的提交**不允许单独存在**(docs-convention §2 门),必须与 wiki/CHANGELOG 同步在同一次交付内。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_package.py -v` → PASS;`make ci` 全绿。
|
||||||
|
|
||||||
|
**提交**: `chore: release 1.1.0 with the response observability fields`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合并前门(逐条对应 CLAUDE.md §3)
|
||||||
|
|
||||||
|
- [ ] 每个行为变更都有「先失败后通过」的测试证据(T1-T6 各自的新增用例)。
|
||||||
|
- [ ] `make ci` 全绿(ruff + import-linter + pytest + 覆盖率)。
|
||||||
|
- [ ] 派**全新上下文**的 verifier subagent 独立验证(跨多文件,`verification-before-completion` 强制档)。
|
||||||
|
- [ ] 合并前整分支代码审查(`requesting-code-review`)。
|
||||||
|
- [ ] Gitea Issue #3 的关闭说明:两个字段的最终名字与语义、缓存命中行的回放口径、遥测新列与补列行为。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:response-observability-fields
|
||||||
|
title: 响应可观测字段扩展实现计划
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 响应可观测字段扩展实现计划
|
||||||
|
|
||||||
|
全文见 `2026-07-31-response-observability-fields.md`。实现 [[response-observability-fields]] 设计(A2/B1/C1/D1)。
|
||||||
|
|
||||||
|
## 任务序列
|
||||||
|
|
||||||
|
| 任务 | 内容 | 提交 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | `types.py` 两个类型各 +2 字段;`cache_hit` docstring 消歧 | 独立 |
|
||||||
|
| T2 | `openai_compat.py` 防御解析 + SSE sink 采集 `model` + 两处构造填值 | 独立 |
|
||||||
|
| T3 | `retry.py` 搬运;`cache.py` 零改动但用测试固化 B1 回放语义 | 独立 |
|
||||||
|
| T4 | `pricing.py` 可选缓存单价档 + 夹取防负 | 独立 |
|
||||||
|
| T5+T6 | 端口 18→20、两后端 DDL 加列与幂等补列、emitter 搬运、契约测试 | **必须合一次提交** |
|
||||||
|
| T7 | ARCHITECTURE §7.8 / CHANGELOG / `.env.example:56` / 6 处「18 字段」措辞 / 版本 1.1.0 / Gitea Wiki 站 | 独立 |
|
||||||
|
|
||||||
|
## 独立审查抓出的四个坑(已折回计划)
|
||||||
|
|
||||||
|
1. **DDL 新列必须放在 `created_at` 之后**(表末尾)。旧表走 `ALTER ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前,两条路径列序分叉 —— 而 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该 PG 表与真实批跑共享、严禁 DROP,分叉后无合规修法。
|
||||||
|
2. **SQLite 补列块首行必须守卫 `if self._conn is None: return`**。否则初始化失败时补列块抛 `AttributeError`/`NameError`(不被 `sqlite3.Error` 捕获)逃出 `__init__`,打破「初始化失败静默降级」契约。
|
||||||
|
3. **T5 与 T6 不得分开提交**。中间状态下 emitter 只传 18 键,后端抛 `KeyError` 被吞成 warning,该 commit 全量遥测静默丢失。
|
||||||
|
4. **天然拦截点是四处而非三处**:两个 `_record_minimal` + 两个 `_EXPECTED_COLUMNS`;`test_ports.py:96` 的全签名 fake **不会**红(Protocol 的 isinstance 不校验签名),不能当作覆盖保证。
|
||||||
|
|
||||||
|
相关: [[response-observability-fields]]、[[est-tokens-decoupling]]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
type: schema
|
type: schema
|
||||||
node_id: schema:llm-calls
|
node_id: schema:llm-calls
|
||||||
title: "表结构: llm_calls(遥测 18 字段)"
|
title: "表结构: llm_calls(遥测 20 字段)"
|
||||||
date: 2026-07-20
|
date: 2026-07-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# 表结构: llm_calls(遥测 18 字段)
|
# 表结构: llm_calls(遥测 20 字段)
|
||||||
|
|
||||||
|
|
||||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||||
@@ -24,6 +24,8 @@ date: 2026-07-20
|
|||||||
| error | TEXT | 异常信息;取消记 "cancelled" |
|
| error | TEXT | 异常信息;取消记 "cancelled" |
|
||||||
| cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
|
| cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
|
||||||
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
|
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
|
||||||
|
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
|
||||||
|
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
||||||
|
|
||||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||||
|
|
||||||
@@ -35,6 +37,19 @@ date: 2026-07-20
|
|||||||
|
|
||||||
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
||||||
|
|
||||||
|
## 供应商 prompt cache 口径(2026-07-31,issue #3)
|
||||||
|
|
||||||
|
新增两列排在 `created_at` **之后**——旧表只能经 `ALTER TABLE ADD COLUMN` 追加到末尾,DDL 里若插在前面,新建库与升级库的物理列序会分叉(列序断言无合规修法)。两个后端在初始化期幂等补列:`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入被逐行 warning 丢弃、遥测静默全失。两侧都**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,遥测是内联 await,锁共享审计表会拖垮业务调用),且**补列失败只降级为逐行丢弃,绝不让 recorder 整体失能**——两侧纪律必须对称。
|
||||||
|
|
||||||
|
`cache_hit` 指 **PolyGateway 自身响应缓存**,与供应商 prompt cache 是两回事。缓存命中行的这两列是**原样回放**的历史值(与 `model`/`prompt_tokens` 同一口径),故命中率度量口径固定为:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0)
|
||||||
|
FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
`WHERE cache_hit = false` 不可省,理由与上面 cost 缺口口径同源:回放行计入即重复计数。
|
||||||
|
|
||||||
## 埋点位置(单一 helper 铁律)
|
## 埋点位置(单一 helper 铁律)
|
||||||
|
|
||||||
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.0.3"
|
__version__ = "1.0.4"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ class RetryMW:
|
|||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
cost=None,
|
cost=None,
|
||||||
usage_source=result.usage_source,
|
usage_source=result.usage_source,
|
||||||
|
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||||
|
model_reported=result.model_reported,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 18 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 20 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||||
self._recorder = recorder
|
self._recorder = recorder
|
||||||
@@ -61,6 +61,8 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
error=error,
|
||||||
|
cached_prompt_tokens=response.cached_prompt_tokens if response else None,
|
||||||
|
model_reported=response.model_reported if response else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||||
@@ -81,6 +83,10 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
error=None,
|
error=None,
|
||||||
|
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
||||||
|
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
||||||
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||||
|
model_reported=response.model_reported,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_terminal_failure(
|
async def emit_terminal_failure(
|
||||||
@@ -103,6 +109,8 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
error=error,
|
||||||
|
cached_prompt_tokens=None,
|
||||||
|
model_reported=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _record(
|
async def _record(
|
||||||
@@ -123,6 +131,8 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms: float | None,
|
max_inter_token_ms: float | None,
|
||||||
cache_hit: bool,
|
cache_hit: bool,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
|
cached_prompt_tokens: int | None,
|
||||||
|
model_reported: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -134,7 +144,9 @@ class TelemetryEmitter:
|
|||||||
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||||
cost = None
|
cost = None
|
||||||
elif error is None and model and self._pricing is not None:
|
elif error is None and model and self._pricing is not None:
|
||||||
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
cost = self._pricing.cost(
|
||||||
|
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
cost = None
|
cost = None
|
||||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||||
@@ -158,6 +170,8 @@ class TelemetryEmitter:
|
|||||||
cache_hit=cache_hit,
|
cache_hit=cache_hit,
|
||||||
error=error,
|
error=error,
|
||||||
cost=cost,
|
cost=cost,
|
||||||
|
cached_prompt_tokens=cached_prompt_tokens,
|
||||||
|
model_reported=model_reported,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -245,7 +245,11 @@ class StructuredOutputStrategy(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TelemetryRecorder(Protocol):
|
class TelemetryRecorder(Protocol):
|
||||||
"""遥测后端;18 字段冻结(M1 设计 §4.4),唯一调用点是 TelemetryEmitter。"""
|
"""遥测后端;20 字段冻结(M1 设计 §4.4 + issue #3),唯一调用点是 TelemetryEmitter。
|
||||||
|
|
||||||
|
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||||
|
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||||
|
"""
|
||||||
|
|
||||||
async def record_llm_call(
|
async def record_llm_call(
|
||||||
self,
|
self,
|
||||||
@@ -268,4 +272,6 @@ class TelemetryRecorder(Protocol):
|
|||||||
cache_hit: bool,
|
cache_hit: bool,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
cost: float | None,
|
cost: float | None,
|
||||||
|
cached_prompt_tokens: int | None,
|
||||||
|
model_reported: str | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**零内置单价**: 实验室走中转网关,计费非官方牌价;库内硬编码单价表
|
**零内置单价**: 实验室走中转网关,计费非官方牌价;库内硬编码单价表
|
||||||
必然过时并掩盖真实成本(P5 严禁默认值掩盖错误)。价格一律由使用方
|
必然过时并掩盖真实成本(P5 严禁默认值掩盖错误)。价格一律由使用方
|
||||||
提供——JSON 文件(`PGW_PRICING_PATH`)或 dict 注入;币种由使用方全表
|
提供——JSON 文件(`PGW_PRICING_PATH`)或 dict 注入;币种由使用方全表
|
||||||
统一口径,库不设币种字段(18 字段冻结)。查不到的 model → cost=None
|
统一口径,库不设币种字段(20 字段冻结)。查不到的 model → cost=None
|
||||||
且每 model 仅首次 warning(防日志风暴),不阻塞调用。
|
且每 model 仅首次 warning(防日志风暴),不阻塞调用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -22,22 +22,33 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ModelPrice:
|
class ModelPrice:
|
||||||
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。"""
|
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。
|
||||||
|
|
||||||
|
`cached_input_per_1m` 是可选的**缓存读取单价**(issue #3): 供应商 prompt
|
||||||
|
cache 命中的那部分输入按更低单价计费。不填即不启用——库绝不按经验折扣率
|
||||||
|
猜一个数(P5 严禁默认值掩盖),未填时全额按 `input_per_1m` 计。
|
||||||
|
"""
|
||||||
|
|
||||||
input_per_1m: float
|
input_per_1m: float
|
||||||
output_per_1m: float
|
output_per_1m: float
|
||||||
|
cached_input_per_1m: float | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.input_per_1m < 0 or self.output_per_1m < 0:
|
if self.input_per_1m < 0 or self.output_per_1m < 0:
|
||||||
raise ValueError("单价不能为负")
|
raise ValueError("单价不能为负")
|
||||||
|
if self.cached_input_per_1m is not None and self.cached_input_per_1m < 0:
|
||||||
|
raise ValueError("缓存读取单价不能为负")
|
||||||
|
|
||||||
|
|
||||||
class PricingTable:
|
class PricingTable:
|
||||||
"""model → 单价 的只读表;cost() 是全库唯一换算点(经 TelemetryEmitter)。"""
|
"""model → 单价 的只读表;cost() 有两个调用点: `TelemetryEmitter`(chat 主路径)
|
||||||
|
与 `embedding.py` 的批量换算。"""
|
||||||
|
|
||||||
def __init__(self, prices: Mapping[str, ModelPrice]) -> None:
|
def __init__(self, prices: Mapping[str, ModelPrice]) -> None:
|
||||||
self._prices = dict(prices)
|
self._prices = dict(prices)
|
||||||
self._warned: set[str] = set()
|
self._warned: set[str] = set()
|
||||||
|
# 独立集合: 与"未知 model"的告警去重键分开,避免 model 名恰好撞上时互相抑制
|
||||||
|
self._warned_clamp: set[str] = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, path: Path | str) -> PricingTable:
|
def from_file(cls, path: Path | str) -> PricingTable:
|
||||||
@@ -53,21 +64,61 @@ class PricingTable:
|
|||||||
for model, entry in data.items():
|
for model, entry in data.items():
|
||||||
if not isinstance(entry, dict) or not {"input_per_1m", "output_per_1m"} <= set(entry):
|
if not isinstance(entry, dict) or not {"input_per_1m", "output_per_1m"} <= set(entry):
|
||||||
raise ValueError(f"价格表 {p} 条目 {model!r} 须含 input_per_1m 与 output_per_1m")
|
raise ValueError(f"价格表 {p} 条目 {model!r} 须含 input_per_1m 与 output_per_1m")
|
||||||
|
cached_raw = entry.get("cached_input_per_1m")
|
||||||
|
try:
|
||||||
|
cached = None if cached_raw is None else float(cached_raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 必须是数字: {cached_raw!r}"
|
||||||
|
) from exc
|
||||||
|
if cached is not None and cached < 0:
|
||||||
|
raise ValueError(f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 不能为负")
|
||||||
prices[model] = ModelPrice(
|
prices[model] = ModelPrice(
|
||||||
input_per_1m=float(entry["input_per_1m"]),
|
input_per_1m=float(entry["input_per_1m"]),
|
||||||
output_per_1m=float(entry["output_per_1m"]),
|
output_per_1m=float(entry["output_per_1m"]),
|
||||||
|
cached_input_per_1m=cached,
|
||||||
)
|
)
|
||||||
return cls(prices)
|
return cls(prices)
|
||||||
|
|
||||||
def cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float | None:
|
def cost(
|
||||||
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。"""
|
self,
|
||||||
|
model: str,
|
||||||
|
prompt_tokens: int,
|
||||||
|
completion_tokens: int,
|
||||||
|
cached_prompt_tokens: int | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。
|
||||||
|
|
||||||
|
`cached_prompt_tokens` 是供应商 prompt cache 命中的输入 token 数
|
||||||
|
(issue #3);仅当该 model 配了 `cached_input_per_1m` 时才分段计价,
|
||||||
|
否则全额按输入价——不猜折扣率。参数带默认值: embedding 侧的三参调用
|
||||||
|
形态不受影响。
|
||||||
|
"""
|
||||||
price = self._prices.get(model)
|
price = self._prices.get(model)
|
||||||
if price is None:
|
if price is None:
|
||||||
if model not in self._warned:
|
if model not in self._warned:
|
||||||
self._warned.add(model)
|
self._warned.add(model)
|
||||||
logger.warning("pricing 表无 model {!r} 的单价,cost 记 None", model)
|
logger.warning("pricing 表无 model {!r} 的单价,cost 记 None", model)
|
||||||
return None
|
return None
|
||||||
return (
|
billed_input = prompt_tokens / 1_000_000 * price.input_per_1m
|
||||||
prompt_tokens / 1_000_000 * price.input_per_1m
|
# 负数按"无命中"处理: cost() 是公共方法,不能假定调用方已过 transport 的校验
|
||||||
+ completion_tokens / 1_000_000 * price.output_per_1m
|
if price.cached_input_per_1m is not None and (cached_prompt_tokens or 0) > 0:
|
||||||
|
cached = self._clamp_cached(model, prompt_tokens, cached_prompt_tokens)
|
||||||
|
billed_input = (prompt_tokens - cached) / 1_000_000 * price.input_per_1m + (
|
||||||
|
cached / 1_000_000 * price.cached_input_per_1m
|
||||||
)
|
)
|
||||||
|
return billed_input + completion_tokens / 1_000_000 * price.output_per_1m
|
||||||
|
|
||||||
|
def _clamp_cached(self, model: str, prompt_tokens: int, cached: int) -> int:
|
||||||
|
"""命中数按输入总数夹取: 网关口径异常不得算出负成本(每 model 只警告一次)。"""
|
||||||
|
if cached <= prompt_tokens:
|
||||||
|
return cached
|
||||||
|
if model not in self._warned_clamp:
|
||||||
|
self._warned_clamp.add(model)
|
||||||
|
logger.warning(
|
||||||
|
"model {!r} 上报的缓存命中 {} 超过输入总数 {},按总数夹取计价",
|
||||||
|
model,
|
||||||
|
cached,
|
||||||
|
prompt_tokens,
|
||||||
|
)
|
||||||
|
return prompt_tokens
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
||||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||||
构造不连库(lazy),18 列 schema 与 SQLite 版同名同序。
|
构造不连库(lazy),20 列 schema 与 SQLite 版同名同序。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -39,10 +39,24 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
cost DOUBLE PRECISION,
|
cost DOUBLE PRECISION,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
cached_prompt_tokens INTEGER,
|
||||||
|
model_reported TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
|
||||||
|
_BACKFILL = (
|
||||||
|
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
||||||
|
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
||||||
|
_EXISTING_COLUMNS = (
|
||||||
|
"SELECT attname FROM pg_attribute "
|
||||||
|
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||||
|
)
|
||||||
|
|
||||||
_COLUMNS = (
|
_COLUMNS = (
|
||||||
"call_id",
|
"call_id",
|
||||||
"parent_call_id",
|
"parent_call_id",
|
||||||
@@ -62,6 +76,8 @@ _COLUMNS = (
|
|||||||
"cache_hit",
|
"cache_hit",
|
||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
@@ -104,6 +120,7 @@ class PostgresRecorder:
|
|||||||
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
||||||
async with self._pool.acquire() as conn:
|
async with self._pool.acquire() as conn:
|
||||||
await conn.execute(_DDL)
|
await conn.execute(_DDL)
|
||||||
|
await self._backfill_columns(conn)
|
||||||
self._schema_ready = True
|
self._schema_ready = True
|
||||||
return self._pool
|
return self._pool
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -113,6 +130,29 @@ class PostgresRecorder:
|
|||||||
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _backfill_columns(self, conn: object) -> None:
|
||||||
|
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
||||||
|
|
||||||
|
两条纪律各有实测理由:
|
||||||
|
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
||||||
|
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||||
|
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||||
|
(SQLite 侧同款守卫,两侧必须对称)。
|
||||||
|
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
||||||
|
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
||||||
|
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||||
|
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||||
|
for column, statement in _BACKFILL:
|
||||||
|
if column not in existing:
|
||||||
|
await conn.execute(statement) # type: ignore[attr-defined]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
||||||
pool = await self._ensure_ready()
|
pool = await self._ensure_ready()
|
||||||
|
|||||||
@@ -34,10 +34,16 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
cost REAL,
|
cost REAL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
cached_prompt_tokens INTEGER,
|
||||||
|
model_reported TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||||
|
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||||
|
_BACKFILL_COLUMNS = (("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT"))
|
||||||
|
|
||||||
_COLUMNS = (
|
_COLUMNS = (
|
||||||
"call_id",
|
"call_id",
|
||||||
"parent_call_id",
|
"parent_call_id",
|
||||||
@@ -57,6 +63,8 @@ _COLUMNS = (
|
|||||||
"cache_hit",
|
"cache_hit",
|
||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
@@ -82,9 +90,37 @@ class SQLiteRecorder:
|
|||||||
self._conn = conn
|
self._conn = conn
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||||
|
self._backfill_columns()
|
||||||
|
|
||||||
|
def _backfill_columns(self) -> None:
|
||||||
|
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||||
|
|
||||||
|
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||||
|
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||||
|
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||||
|
比逐行丢弃严重得多。
|
||||||
|
"""
|
||||||
|
if self._conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
||||||
|
return
|
||||||
|
for column, decl in _BACKFILL_COLUMNS:
|
||||||
|
if column in existing:
|
||||||
|
continue
|
||||||
|
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
|
||||||
|
try:
|
||||||
|
self._conn.execute(f"ALTER TABLE llm_calls ADD COLUMN {column} {decl}")
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
# duplicate column: 多进程共库时后到者必然撞上,属预期竞态,视为成功
|
||||||
|
if "duplicate column" not in str(exc).lower():
|
||||||
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 18 字段冻结签名(ports.TelemetryRecorder)。"""
|
"""写一行遥测;字段集合即 20 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in _COLUMNS)
|
row = tuple(fields[col] for col in _COLUMNS)
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool,
|
|||||||
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
||||||
if chunk.get("usage"):
|
if chunk.get("usage"):
|
||||||
usage_sink["usage"] = chunk["usage"]
|
usage_sink["usage"] = chunk["usage"]
|
||||||
|
if "model" not in usage_sink:
|
||||||
|
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死
|
||||||
|
# sink——否则后续真实版本会丢(issue #3)
|
||||||
|
reported = _coerce_model_reported(chunk.get("model"))
|
||||||
|
if reported is not None:
|
||||||
|
usage_sink["model"] = reported
|
||||||
choices = chunk.get("choices") or []
|
choices = chunk.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
return None
|
return None
|
||||||
@@ -152,6 +158,35 @@ def _resolve_usage(usage: dict[str, Any]) -> tuple[int, int, str]:
|
|||||||
return 0, 0, "unavailable"
|
return 0, 0, "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_cached_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.prompt_tokens_details.cached_tokens(issue #3);形态异常一律 None。
|
||||||
|
|
||||||
|
`0` 与 `None` 必须可区分: 前者是"该源上报了一次真实零命中",后者是"该源
|
||||||
|
不报这个数",下游对两者的处置不同(后者不可做缓存成本校正)。故只把
|
||||||
|
**负数与非整数**归 None,`0` 如实保留。`bool` 显式排除——isinstance(True, int)
|
||||||
|
在 Python 里为真,放行会把 `True` 记成 1 个命中 token。
|
||||||
|
"""
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
details = usage.get("prompt_tokens_details")
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None
|
||||||
|
cached = details.get("cached_tokens")
|
||||||
|
if isinstance(cached, bool) or not isinstance(cached, int) or cached < 0:
|
||||||
|
return None
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_model_reported(value: Any) -> str | None:
|
||||||
|
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
|
||||||
|
|
||||||
|
去空白不是洁癖: 下游拿这个串做实验快照的 key,`" m "` 与 `"m"` 会造成假分叉。
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
|
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
|
||||||
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
|
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
|
||||||
|
|
||||||
@@ -360,6 +395,8 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=ttft_ms,
|
ttft_ms=ttft_ms,
|
||||||
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
|
model_reported=_coerce_model_reported(sink.get("model")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _check_done(
|
||||||
@@ -442,6 +479,8 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
|
model_reported=_coerce_model_reported(body.get("model")),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -30,12 +30,20 @@ class LLMResponse:
|
|||||||
ttft_ms: float | None
|
ttft_ms: float | None
|
||||||
max_inter_token_ms: float | None
|
max_inter_token_ms: float | None
|
||||||
cache_hit: bool
|
cache_hit: bool
|
||||||
|
"""**PolyGateway 自身响应缓存**命中(未产生网关调用);与供应商侧 prompt
|
||||||
|
cache 无关,后者见 `cached_prompt_tokens`。"""
|
||||||
call_id: str
|
call_id: str
|
||||||
# —— 库新增(只增不删,必带默认值;迁移兼容硬约束)——
|
# —— 库新增(只增不删,必带默认值;迁移兼容硬约束)——
|
||||||
source_name: str = ""
|
source_name: str = ""
|
||||||
cost: float | None = None
|
cost: float | None = None
|
||||||
usage_source: str = "measured"
|
usage_source: str = "measured"
|
||||||
structured_data: Any | None = None
|
structured_data: Any | None = None
|
||||||
|
cached_prompt_tokens: int | None = None
|
||||||
|
"""供应商 prompt cache 命中的输入 token 数(issue #3);None = 该源未上报,
|
||||||
|
与"上报了但是 0"(真实零命中)区分——两者对下游的处置不同。"""
|
||||||
|
model_reported: str | None = None
|
||||||
|
"""API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能
|
||||||
|
分叉——供应商把别名指向新权重时,实验复现必须认这个串。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -82,6 +90,9 @@ class TransportResult:
|
|||||||
ttft_ms: float | None
|
ttft_ms: float | None
|
||||||
max_inter_token_ms: float | None
|
max_inter_token_ms: float | None
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
# —— 可观测字段(issue #3;带默认值,非 OpenAI 兼容的 transport 可不填)——
|
||||||
|
cached_prompt_tokens: int | None = None
|
||||||
|
model_reported: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
]
|
]
|
||||||
|
|
||||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||||
@@ -100,6 +102,8 @@ async def _record_minimal(
|
|||||||
"cache_hit": False,
|
"cache_hit": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
"cost": None,
|
"cost": None,
|
||||||
|
"cached_prompt_tokens": None,
|
||||||
|
"model_reported": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -115,6 +119,105 @@ async def _fetch(dsn: str, sql: str, *args):
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_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()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def legacy_schema(dsn):
|
||||||
|
"""在**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。
|
||||||
|
|
||||||
|
绝不碰共享的 public.llm_calls: 用 search_path 把 recorder 指向临时 schema,
|
||||||
|
teardown 只 DROP 自己建的 schema。
|
||||||
|
"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
name = f"pgwtest_{uuid4().hex[:8]}"
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute(f"CREATE SCHEMA {name}")
|
||||||
|
await conn.execute(_LEGACY_DDL.format(schema=name))
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
sep = "&" if "?" in dsn else "?"
|
||||||
|
yield f"{dsn}{sep}options=-csearch_path%3D{name}", name
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityColumns:
|
||||||
|
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||||
|
|
||||||
|
async def test_values_round_trip(self, dsn):
|
||||||
|
recorder = PostgresRecorder(dsn)
|
||||||
|
try:
|
||||||
|
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||||
|
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01")
|
||||||
|
rows = await _fetch(
|
||||||
|
dsn,
|
||||||
|
"SELECT call_id, cached_prompt_tokens, model_reported FROM llm_calls "
|
||||||
|
"WHERE call_id LIKE $1",
|
||||||
|
f"{_RUN_PREFIX}-%",
|
||||||
|
)
|
||||||
|
by_id = {r["call_id"]: r for r in rows}
|
||||||
|
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64
|
||||||
|
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
|
||||||
|
assert by_id[_cid("model")]["cached_prompt_tokens"] is None
|
||||||
|
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01"
|
||||||
|
finally:
|
||||||
|
await recorder.aclose()
|
||||||
|
|
||||||
|
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
||||||
|
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
||||||
|
schema_dsn, schema = legacy_schema
|
||||||
|
recorder = PostgresRecorder(schema_dsn)
|
||||||
|
try:
|
||||||
|
await _record_minimal(
|
||||||
|
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# ALTER 只能追加到末尾: 与新建库的列序一致才不会分叉
|
||||||
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
|
rows = await _fetch(
|
||||||
|
schema_dsn,
|
||||||
|
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1",
|
||||||
|
_cid("legacy"),
|
||||||
|
)
|
||||||
|
assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
|
||||||
|
finally:
|
||||||
|
await recorder.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestSchema:
|
class TestSchema:
|
||||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn)
|
||||||
|
|||||||
@@ -150,6 +150,49 @@ class TestCacheFlow:
|
|||||||
assert terminal.calls == 2
|
assert terminal.calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityFieldsOnHit:
|
||||||
|
"""issue #3 决策 B1: 命中行原样回放,与 model/prompt_tokens 同一口径。"""
|
||||||
|
|
||||||
|
async def test_fields_replayed_on_hit(self):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(
|
||||||
|
_resp(cached_prompt_tokens=64, model_reported="MiniMax-Text-01-250321")
|
||||||
|
)
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True
|
||||||
|
assert hit.cached_prompt_tokens == 64
|
||||||
|
assert hit.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_legacy_cache_entry_without_new_keys_rehydrates(self):
|
||||||
|
"""旧格式条目(无这两个键)必须照常重建为 None,不得抛异常回源。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
legacy = {
|
||||||
|
"content": "legacy",
|
||||||
|
"thinking": "",
|
||||||
|
"model": "m",
|
||||||
|
"provider": "p",
|
||||||
|
"prompt_tokens": 1,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"latency_ms": 30,
|
||||||
|
"ttft_ms": 5.0,
|
||||||
|
"max_inter_token_ms": 2.0,
|
||||||
|
"cache_hit": False,
|
||||||
|
"call_id": "orig",
|
||||||
|
"source_name": "s1",
|
||||||
|
"cost": None,
|
||||||
|
"usage_source": "measured",
|
||||||
|
}
|
||||||
|
await backend.set(key, json.dumps(legacy), ttl_s=100)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
|
||||||
|
assert hit.cached_prompt_tokens is None and hit.model_reported is None
|
||||||
|
|
||||||
|
|
||||||
class _BrokenBackend:
|
class _BrokenBackend:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
raise ConnectionError("redis down")
|
raise ConnectionError("redis down")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def _source(**overrides):
|
|||||||
return SourceConfig(**base)
|
return SourceConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
def _chunk(content=None, reasoning=None, usage=None):
|
def _chunk(content=None, reasoning=None, usage=None, model=None):
|
||||||
delta = {}
|
delta = {}
|
||||||
if content is not None:
|
if content is not None:
|
||||||
delta["content"] = content
|
delta["content"] = content
|
||||||
@@ -45,6 +45,8 @@ def _chunk(content=None, reasoning=None, usage=None):
|
|||||||
body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []}
|
body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []}
|
||||||
if usage is not None:
|
if usage is not None:
|
||||||
body["usage"] = usage
|
body["usage"] = usage
|
||||||
|
if model is not None:
|
||||||
|
body["model"] = model
|
||||||
return f"data: {json.dumps(body)}\n\n"
|
return f"data: {json.dumps(body)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -261,6 +263,137 @@ class TestEmptyCompletion:
|
|||||||
await _complete(_transport_for(handler), _source())
|
await _complete(_transport_for(handler), _source())
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityFields:
|
||||||
|
"""issue #3: 供应商 prompt cache 命中数与 API 实际返回的模型版本串。
|
||||||
|
|
||||||
|
网关报文一律不可信: 形态异常只归 None,绝不因一个可观测字段打断调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _cached_usage(self, cached):
|
||||||
|
return {**_USAGE, "prompt_tokens_details": {"cached_tokens": cached}}
|
||||||
|
|
||||||
|
async def test_stream_reads_cached_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(128)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens == 128
|
||||||
|
|
||||||
|
async def test_non_stream_reads_cached_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._cached_usage(128),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.cached_prompt_tokens == 128
|
||||||
|
|
||||||
|
async def test_zero_cached_tokens_is_a_real_zero(self):
|
||||||
|
"""0(真实零命中)与 None(该源未上报)必须可区分——issue #3 的核心诉求。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens == 0
|
||||||
|
|
||||||
|
async def test_usage_without_details_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_missing_usage_frame_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
|
||||||
|
async def test_malformed_cached_tokens_degrade_to_none(self, bad):
|
||||||
|
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(bad)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_details_not_a_dict_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
usage = {**_USAGE, "prompt_tokens_details": "oops"}
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_non_stream_reads_reported_model(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": _USAGE,
|
||||||
|
"model": "MiniMax-Text-01-250321",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_stream_keeps_the_first_reported_model(self):
|
||||||
|
"""末帧异常值不得覆盖首帧: 首次写入即固定。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(content="a", model="MiniMax-Text-01-250321"),
|
||||||
|
_chunk(content="b", model="something-else"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_empty_first_model_does_not_block_a_later_real_one(self):
|
||||||
|
"""首帧报空串不得锁死 sink: 守卫按"有效值"判断,否则真实版本会丢。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(content="a", model=""),
|
||||||
|
_chunk(content="b", model="MiniMax-Text-01-250321"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, "", " ", 123, {}])
|
||||||
|
async def test_missing_or_malformed_model_is_none(self, bad):
|
||||||
|
def handler(request):
|
||||||
|
body = {"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
if bad is not None:
|
||||||
|
body["model"] = bad
|
||||||
|
return httpx.Response(200, json=body)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.model_reported is None
|
||||||
|
|
||||||
|
async def test_raw_payload_is_unchanged(self):
|
||||||
|
"""新字段是独立格子,不改动 raw 的既有内容。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(5)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert set(result.raw) == {"usage"}
|
||||||
|
|
||||||
|
|
||||||
class TestNonStreamFastPath:
|
class TestNonStreamFastPath:
|
||||||
async def test_non_stream_parses_message(self):
|
async def test_non_stream_parses_message(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ class _DummyRecorder:
|
|||||||
cache_hit,
|
cache_hit,
|
||||||
error,
|
error,
|
||||||
cost,
|
cost,
|
||||||
|
cached_prompt_tokens,
|
||||||
|
model_reported,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,74 @@ class TestPricingTable:
|
|||||||
ModelPrice(input_per_1m=-1.0, output_per_1m=0.0)
|
ModelPrice(input_per_1m=-1.0, output_per_1m=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachedInputTier:
|
||||||
|
"""issue #3: 供应商 prompt cache 命中部分按更低单价计费,不配则不猜折扣。"""
|
||||||
|
|
||||||
|
_CACHED = PricingTable(
|
||||||
|
{"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0, cached_input_per_1m=2.0)}
|
||||||
|
)
|
||||||
|
_PLAIN = PricingTable({"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0)})
|
||||||
|
|
||||||
|
def test_hit_is_billed_at_the_cached_rate(self):
|
||||||
|
# 1M prompt 中 600k 命中: 400k×10 + 600k×2 = 4.0 + 1.2
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, 600_000) == pytest.approx(5.2)
|
||||||
|
|
||||||
|
def test_without_the_tier_the_result_is_unchanged(self):
|
||||||
|
"""未配缓存档 = 退化为现状全额计价,绝不按经验折扣率猜(P5)。"""
|
||||||
|
full = self._PLAIN.cost("m", 1_000_000, 0)
|
||||||
|
assert self._PLAIN.cost("m", 1_000_000, 0, 600_000) == full == pytest.approx(10.0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cached", [None, 0])
|
||||||
|
def test_no_hit_is_billed_in_full(self, cached):
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, cached) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_negative_cached_is_billed_in_full(self):
|
||||||
|
"""负数命中数不得抬高成本: cost() 是公共方法,外部输入须校验后使用(P5)。"""
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, -500_000) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_cached_over_prompt_is_clamped_and_never_negative(self):
|
||||||
|
"""网关口径异常时按输入总数夹取: 全部按缓存价,不得算出负成本。"""
|
||||||
|
clamped = self._CACHED.cost("m", 1_000_000, 0, 5_000_000)
|
||||||
|
assert clamped == pytest.approx(2.0) and clamped >= 0
|
||||||
|
|
||||||
|
def test_legacy_three_arg_call_still_works(self):
|
||||||
|
"""embedding.py 的三参调用形态必须零改动可用。"""
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_from_file_accepts_and_validates_the_tier(self, tmp_path):
|
||||||
|
path = tmp_path / "p.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"m": {"input_per_1m": 10.0, "output_per_1m": 20.0, "cached_input_per_1m": 2.0}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert PricingTable.from_file(path).cost("m", 1_000_000, 0, 1_000_000) == pytest.approx(2.0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [-1.0, "x"])
|
||||||
|
def test_from_file_rejects_a_bad_tier(self, tmp_path, bad):
|
||||||
|
path = tmp_path / "bad.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"m": {"input_per_1m": 1.0, "output_per_1m": 2.0, "cached_input_per_1m": bad}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="cached_input_per_1m"):
|
||||||
|
PricingTable.from_file(path)
|
||||||
|
|
||||||
|
def test_legacy_price_file_without_the_tier_still_loads(self, tmp_path):
|
||||||
|
path = tmp_path / "old.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert PricingTable.from_file(path).cost("m", 1_000_000, 0, 500_000) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_negative_tier_rejected_on_construction(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ModelPrice(input_per_1m=1.0, output_per_1m=1.0, cached_input_per_1m=-0.1)
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rows = []
|
self.rows = []
|
||||||
|
|||||||
@@ -194,6 +194,35 @@ class TestSuccessPath:
|
|||||||
assert (await limiter.source_stats("a")).tpm_used == 16
|
assert (await limiter.source_stats("a")).tpm_used == 16
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityPassthrough:
|
||||||
|
"""issue #3: transport 采到的两个可观测字段必须原样上浮到 LLMResponse。"""
|
||||||
|
|
||||||
|
async def test_fields_reach_the_response(self):
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=5,
|
||||||
|
usage_source="measured",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
cached_prompt_tokens=64,
|
||||||
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
)
|
||||||
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.cached_prompt_tokens == 64
|
||||||
|
assert resp.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
|
||||||
|
assert resp.model == "m"
|
||||||
|
|
||||||
|
async def test_absent_fields_stay_none(self):
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
async def test_transient_switches_source_then_succeeds(self):
|
async def test_transient_switches_source_then_succeeds(self):
|
||||||
mw, _, _, transport, sleep, _ = _harness(
|
mw, _, _, transport, sleep, _ = _harness(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""遥测子系统测试: SQLiteRecorder(18 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
"""遥测子系统测试: SQLiteRecorder(20 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -35,6 +35,8 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +95,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
"cache_hit": False,
|
"cache_hit": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
"cost": None,
|
"cost": None,
|
||||||
|
"cached_prompt_tokens": None,
|
||||||
|
"model_reported": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -134,6 +138,158 @@ class TestSQLiteRecorder:
|
|||||||
await _record_minimal(recorder) # 不抛
|
await _record_minimal(recorder) # 不抛
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
|
async def test_observability_columns_round_trip(self, tmp_path):
|
||||||
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
|
await _record_minimal(recorder, call_id="c-hit", cached_prompt_tokens=64)
|
||||||
|
await _record_minimal(recorder, call_id="c-zero", cached_prompt_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id="c-none", model_reported="MiniMax-Text-01")
|
||||||
|
recorder.close()
|
||||||
|
rows = dict(
|
||||||
|
sqlite3.connect(tmp_path / "t.db")
|
||||||
|
.execute("SELECT call_id, cached_prompt_tokens FROM llm_calls")
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
assert rows["c-hit"] == 64
|
||||||
|
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
|
||||||
|
assert rows["c-none"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLiteColumnBackfill:
|
||||||
|
"""issue #3: 已存在的 18 列旧表必须自动补列,否则每行写入都被丢弃。"""
|
||||||
|
|
||||||
|
_LEGACY_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'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_legacy_table_is_upgraded_in_place(self, tmp_path):
|
||||||
|
db = tmp_path / "legacy.db"
|
||||||
|
legacy = sqlite3.connect(db)
|
||||||
|
legacy.execute(self._LEGACY_DDL)
|
||||||
|
legacy.commit()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db)
|
||||||
|
await _record_minimal(recorder, cached_prompt_tokens=7, model_reported="m-real")
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||||
|
assert cols == _EXPECTED_COLUMNS # ALTER 追加到末尾,与新建库列序一致
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT cached_prompt_tokens, model_reported FROM llm_calls"
|
||||||
|
).fetchone() == (7, "m-real")
|
||||||
|
|
||||||
|
async def test_backfill_failure_keeps_the_recorder_usable(self, tmp_path):
|
||||||
|
"""补列失败只能逐行降级,绝不能把 recorder 整体变成 no-op(设计 D1 纪律)。
|
||||||
|
|
||||||
|
把 llm_calls 建成同名 view: `CREATE TABLE IF NOT EXISTS` 遇 view 静默
|
||||||
|
no-op(不抛),随后的 ALTER 才抛 "Cannot add a column to a view"——正是
|
||||||
|
补列失败这条分支。`_conn` 必须保持非 None,否则整个 recorder 永久失能。
|
||||||
|
"""
|
||||||
|
db = tmp_path / "view.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("CREATE TABLE real_rows (call_id TEXT)")
|
||||||
|
conn.execute("CREATE VIEW llm_calls AS SELECT call_id FROM real_rows")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db) # 不得抛
|
||||||
|
assert recorder._conn is not None # 补列失败 ≠ recorder 失能(D1 纪律)
|
||||||
|
await _record_minimal(recorder) # 不得抛
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePgConn:
|
||||||
|
"""记录执行过的语句;可让 ALTER 抛错以模拟权限不足。"""
|
||||||
|
|
||||||
|
def __init__(self, existing: list[str], *, fail_alter: bool = False):
|
||||||
|
self.existing = existing
|
||||||
|
self.fail_alter = fail_alter
|
||||||
|
self.statements: list[str] = []
|
||||||
|
|
||||||
|
async def execute(self, sql, *args):
|
||||||
|
self.statements.append(sql)
|
||||||
|
if sql.startswith("ALTER TABLE") and self.fail_alter:
|
||||||
|
raise RuntimeError("must be owner of table llm_calls")
|
||||||
|
|
||||||
|
async def fetch(self, sql, *args):
|
||||||
|
self.statements.append(sql)
|
||||||
|
return [{"attname": name} for name in self.existing]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePgPool:
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
|
def acquire(self):
|
||||||
|
conn = self._conn
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return conn
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _Ctx()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostgresBackfillDiscipline:
|
||||||
|
"""PG 补列必须与 SQLite 侧对称: 失败只逐行降级,且稳态不抢排他锁(issue #3)。"""
|
||||||
|
|
||||||
|
_LEGACY = ["call_id", "cost", "created_at"]
|
||||||
|
_CURRENT = ["call_id", "cost", "created_at", "cached_prompt_tokens", "model_reported"]
|
||||||
|
|
||||||
|
def _recorder(self, conn):
|
||||||
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
|
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
|
||||||
|
|
||||||
|
async def test_alter_failure_does_not_disable_the_recorder(self):
|
||||||
|
"""ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。"""
|
||||||
|
conn = _FakePgConn(self._LEGACY, fail_alter=True)
|
||||||
|
recorder = self._recorder(conn)
|
||||||
|
await _record_minimal(recorder) # 不得抛
|
||||||
|
assert recorder._failed is False
|
||||||
|
assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements)
|
||||||
|
|
||||||
|
async def test_no_alter_when_columns_already_exist(self):
|
||||||
|
"""ADD COLUMN IF NOT EXISTS 即使列已存在也会先抢 ACCESS EXCLUSIVE 锁,
|
||||||
|
|
||||||
|
而遥测是内联 await——稳态下必须一条 ALTER 都不发,否则每个进程的首次
|
||||||
|
写入都会去锁共享审计表。
|
||||||
|
"""
|
||||||
|
conn = _FakePgConn(self._CURRENT)
|
||||||
|
await _record_minimal(self._recorder(conn))
|
||||||
|
assert not [s for s in conn.statements if s.startswith("ALTER TABLE")]
|
||||||
|
|
||||||
|
async def test_missing_columns_are_added_once(self):
|
||||||
|
conn = _FakePgConn(self._LEGACY)
|
||||||
|
await _record_minimal(self._recorder(conn))
|
||||||
|
altered = [s for s in conn.statements if s.startswith("ALTER TABLE")]
|
||||||
|
assert len(altered) == 2
|
||||||
|
assert all("IF NOT EXISTS" not in s for s in altered) # 探测已确认缺列,无需再判
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -143,6 +299,155 @@ class _MemoryRecorder:
|
|||||||
self.rows.append(fields)
|
self.rows.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitterRecorderContract:
|
||||||
|
"""emitter 的实参键集合必须与两个后端的 _COLUMNS 完全一致(issue #3)。
|
||||||
|
|
||||||
|
两个后端的 `row = tuple(fields[col] for col in _COLUMNS)` 都在 try **之外**,
|
||||||
|
emitter 漏传一个键就抛 KeyError,被 `_record` 的 except Exception 吞成 warning
|
||||||
|
→ 遥测静默全丢。而 8 个 `**fields` 形态的 fake 一个都拦不住,故显式断言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_emitter_supplies_exactly_the_backend_columns(self):
|
||||||
|
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||||
|
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-1",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert set(rec.rows[0]) == set(SQLITE_COLUMNS) == set(PG_COLUMNS)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
|
||||||
|
async def test_every_entry_point_supplies_the_same_keys(self, emit):
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||||
|
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec)
|
||||||
|
if emit == "attempt":
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
)
|
||||||
|
elif emit == "cache_hit":
|
||||||
|
await emitter.emit_cache_hit(request=_REQ, response=_resp())
|
||||||
|
else:
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert set(rec.rows[0]) == set(SQLITE_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitterObservabilityFields:
|
||||||
|
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
|
||||||
|
|
||||||
|
async def test_attempt_carries_the_response_values(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-1",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(cached_prompt_tokens=64, model_reported="m-real"),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] == 64
|
||||||
|
assert rec.rows[0]["model_reported"] == "m-real"
|
||||||
|
|
||||||
|
async def test_failed_attempt_has_no_provider_facts(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-2",
|
||||||
|
latency_ms=7,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
|
||||||
|
async def test_cache_hit_replays_the_recorded_values(self):
|
||||||
|
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_cache_hit(
|
||||||
|
request=_REQ, response=_resp(cached_prompt_tokens=64, model_reported="m-real")
|
||||||
|
)
|
||||||
|
row = rec.rows[0]
|
||||||
|
assert row["cache_hit"] is True
|
||||||
|
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
|
||||||
|
|
||||||
|
async def test_terminal_failure_records_none(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCostWithCachedTier:
|
||||||
|
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
|
||||||
|
|
||||||
|
_TABLE = PricingTable(
|
||||||
|
{"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0, cached_input_per_1m=2.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_cached_hit_lowers_the_recorded_cost(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec, pricing=self._TABLE)
|
||||||
|
full = _resp(prompt_tokens=1_000_000, completion_tokens=0)
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c1",
|
||||||
|
latency_ms=1,
|
||||||
|
response=full,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c2",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(
|
||||||
|
prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000
|
||||||
|
),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] == pytest.approx(10.0)
|
||||||
|
assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2
|
||||||
|
|
||||||
|
async def test_cache_hit_row_still_costs_zero(self):
|
||||||
|
"""缓存命中未产生新调用 → cost 恒 0.0,该短路必须排在任何换算之前。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=self._TABLE).emit_cache_hit(
|
||||||
|
request=_REQ,
|
||||||
|
response=_resp(prompt_tokens=1_000_000, cached_prompt_tokens=600_000),
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] == 0.0
|
||||||
|
|
||||||
|
async def test_unavailable_usage_still_costs_none(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=self._TABLE).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(usage_source="unavailable", cached_prompt_tokens=5),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestEmitter:
|
class TestEmitter:
|
||||||
async def test_attempt_success_row(self):
|
async def test_attempt_success_row(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
|
|||||||
@@ -49,6 +49,29 @@ class TestLLMResponse:
|
|||||||
assert resp.usage_source == "measured"
|
assert resp.usage_source == "measured"
|
||||||
assert resp.structured_data is None
|
assert resp.structured_data is None
|
||||||
|
|
||||||
|
def test_observability_fields_default_to_none(self):
|
||||||
|
"""issue #3: None = 该源未上报,与"上报了但是 0"区分(0 是真实零命中)。"""
|
||||||
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
|
assert resp.cached_prompt_tokens is None
|
||||||
|
assert resp.model_reported is None
|
||||||
|
filled = LLMResponse(
|
||||||
|
"c",
|
||||||
|
"t",
|
||||||
|
"m",
|
||||||
|
"p",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
"cid",
|
||||||
|
cached_prompt_tokens=0,
|
||||||
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
)
|
||||||
|
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
|
||||||
|
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
def test_frozen(self):
|
def test_frozen(self):
|
||||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
@@ -222,6 +245,8 @@ class TestAuxTypes:
|
|||||||
raw={"id": "x"},
|
raw={"id": "x"},
|
||||||
)
|
)
|
||||||
assert s.raw["id"] == "x"
|
assert s.raw["id"] == "x"
|
||||||
|
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||||
|
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
|
|||||||
Reference in New Issue
Block a user