Compare commits
25 Commits
b693d442f5
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8495cea5dc | |||
| abca723d3d | |||
| 63b85508c7 | |||
| 4e06d5e801 | |||
| 4e5a91d802 | |||
| 9e2d8ee43c | |||
| d1520cc0a5 | |||
| ab496bb298 | |||
| cd8bebba00 | |||
| 195454d2e3 | |||
| 42e429eb58 | |||
| 76e7d9594c | |||
| d8e8fd8124 | |||
| e5dbcf5d33 | |||
| 61231f7f6e | |||
| 4534444ad8 | |||
| 9a8f5cea5a | |||
| 637ac51754 | |||
| ac7c86fdee | |||
| fd7d9d330b | |||
| afd6101c08 | |||
| 726f26d8bd | |||
| c9fdff9d55 | |||
| a65b504a3d | |||
| 8c9e1179bc |
+2
-2
@@ -8,11 +8,11 @@ LLM__QWEN__1__BASE_URL=
|
|||||||
LLM__QWEN__1__API_KEY=
|
LLM__QWEN__1__API_KEY=
|
||||||
LLM__QWEN__1__MODEL=
|
LLM__QWEN__1__MODEL=
|
||||||
LLM__QWEN__1__TIMEOUT_S=120
|
LLM__QWEN__1__TIMEOUT_S=120
|
||||||
# 可选(0 = 该闸不启用;TPM > 0 时 EST_TOKENS 必填 > 0):
|
# 可选(0 = 该闸不启用):
|
||||||
# LLM__QWEN__1__MAX_CONCURRENCY=8
|
# LLM__QWEN__1__MAX_CONCURRENCY=8
|
||||||
# LLM__QWEN__1__RPM=60
|
# LLM__QWEN__1__RPM=60
|
||||||
# LLM__QWEN__1__TPM=100000
|
# LLM__QWEN__1__TPM=100000
|
||||||
# LLM__QWEN__1__EST_TOKENS=2000
|
# LLM__QWEN__1__EST_TOKENS=2000 # 可选调优覆盖: TPM 入场预扣量;未填则库按 tpm//60 派生
|
||||||
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
# LLM__QWEN__1__TTFT_TIMEOUT_S=30 # 须与 INTER_TOKEN 成对;0 < inter < ttft < timeout
|
||||||
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
# LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S=15
|
||||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.3(2026-07-30)
|
||||||
|
|
||||||
|
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
||||||
|
|
||||||
|
### 行为收紧/变更(下游请读)
|
||||||
|
|
||||||
|
- **`usage_source` 新增第三个值 `unavailable`。** 值域由 `measured`/`estimated` 两态变三态:`unavailable` 表示用量信息不可得(usage 帧缺失、失败尝试、终态失败),`estimated` 收窄为"有实测数字但可信度降级"(只剩打捞路径这一个生产者:收到 usage 帧但流被截断)。历史库里既有的 `estimated` 行语义不变、读兼容;按 `usage_source` 分支的下游代码需要认识新值。OCR 成功行**不受影响**,仍是 `measured`(0 token 是事实而非未知)。
|
||||||
|
- **用量不可得的行,`cost` 由数值变 NULL。** 此前 usage 帧缺失时库拿 `est_tokens`(按定义是最坏情形上界)当实测值,又整块塞进 `completion_tokens` 换算——输出单价通常是输入的数倍,实测双重高估约 26 倍;`est_tokens=0` 时则算出 `0.0`,让"免费"与"未知"在数据上不可区分。现在这类行如实记 `0/0` + `unavailable` + `cost=NULL`。`SUM(cost)` 天然跳过 NULL,账目缺口用 `WHERE usage_source = 'unavailable' AND cache_hit = false` 量化(**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 仍是事实上的 `0.0`,本无缺口)。成本汇总若此前依赖"cost 非空"的隐含假设,请复核。
|
||||||
|
- **`est_tokens` 由必填降为可选调优覆盖。** 装配校验 `tpm > 0 ⇒ est_tokens > 0` 已删除——它把供应商配额(运维能从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。未填时库按 `max(1, tpm // 60)` 派生("一次调用约占一秒钟的配额份额",尺度无关:任何配额规模都收敛到约 60 个在途)。字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**,显式填值仍然优先。此前为绕开该校验而把 `tpm` 限死为 0 的调用方,现可填真实 TPM。
|
||||||
|
|
||||||
|
## 1.0.2(2026-07-30)
|
||||||
|
|
||||||
|
1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 `from_env` 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **后端选择与条件必填项在任何构造路径上都校验。** 以下此前只有 `from_env` 拦得住,`from_settings()` 与直接构造一律放行:`limiter_backend`/`breaker_backend`/`cache_backend`/`telemetry_backend`/`selector`/`quota_full` 六个字段的合法域;取 `redis` 的后端必须有 `redis_url`;启用缓存必须有 `cache_namespace` 与正 `cache_ttl_s`;`telemetry_backend` 取 `sqlite`/`postgres` 时对应的路径/DSN 必填;`structured_max_retries` 非负;`scope` 非空。
|
||||||
|
- **`client.py` 五处断言的前提现在真的成立。** `assert settings.redis_url is not None # 内部不变量: config 已校验` 之类的注释此前在 `from_settings` 路上是假的:断言开启时抛不含任何字段信息的 `AssertionError`,`python -O` 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
|
||||||
|
- **构造路补齐了 `from_env` 一直在做的规范化**,两条装配路对同一输入产出同一个值:
|
||||||
|
- `scope` 小写并去空白。它直接进 Redis key(`pgw:limit:{scope}:…`、`pgw:gate:{scope}:…`),此前一个进程走 `from_env("LLM")` 拿到 `llm`、另一个直接构造传 `"LLM"`,**同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间**,各记各的配额与熔断状态,分布式治理静默失效且不报错。
|
||||||
|
- `redis_url`、`pricing_path` 的空串归 `None`。留着空串会骗过 `is None` 判断,把错误推迟成 redis 客户端的连接串解析异常或 `Is a directory: '.'`。
|
||||||
|
- Postgres DSN 剥掉 SQLAlchemy 驱动后缀(`postgresql+asyncpg://…` 的 `+asyncpg` asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 `from_env` 装配的不受影响也不会有这条 warning(`_load_pg_dsn` 早就剥干净了)。
|
||||||
|
- **`EmbeddingSettings` 的 `batch_size` / `expected_dim` 域校验也移入构造期**,此前只有 `EmbeddingSettings.from_env` 校验,直接构造出 `batch_size=-3` 要到 `EmbeddingClient` 构造时才 fail-loud。
|
||||||
|
|
||||||
|
### 行为收紧(下游请读)
|
||||||
|
|
||||||
|
同 1.0.1:经 `from_env()` 装配的调用方**不受影响**。手工构造 `GatewaySettings` 或对它 `dataclasses.replace` 的调用方,若配置组合非法,现在会在构造期抛 `ValueError` 并点出字段名,而不是留到运行时表现为静默不建后端、裸 `AssertionError` 或第三方库的天书报错。
|
||||||
|
|
||||||
|
**一处静默改值需要留意**:此前手工构造传 `scope="LLM"`(非全小写)的调用方,升级后 scope 会被规范化为 `llm`,**Redis key 随之从 `pgw:limit:LLM:…` 切到 `pgw:limit:llm:…`**。这正是本次要修的问题——旧行为下这批 key 与 `from_env` 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。
|
||||||
|
|
||||||
## 1.0.1(2026-07-30)
|
## 1.0.1(2026-07-30)
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ flowchart TB
|
|||||||
### 4.4 一次调用的生命周期(walkthrough)
|
### 4.4 一次调用的生命周期(walkthrough)
|
||||||
|
|
||||||
1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
|
1. **缓存命中**: TelemetryMW 记录(cache_hit=True, latency_ms=0)→ CacheMW 返回,不触达任何更内层。
|
||||||
2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按 `est_tokens` 预扣)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
|
2. **正常路径**: RetryMW 开始第一次尝试 → selector 选源(跳过冷却中的源)→ 该源熔断门(闭路)→ 限流 acquire permit(全局+该源,并发/RPM/TPM 三闸,token 按**有效预扣量** `SourceConfig.effective_est_tokens()` 预扣,取值规则见 §7.7)→ transport 发请求、流式解析(看门狗包裹)、收 usage 帧 → permit 按实际 usage settle(多退少补)→ 回程写缓存 → 遥测记成功(含 ttft/max_inter_token/成本)。
|
||||||
3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。
|
3. **瞬时错误**(超时/5xx/429/SSE 异常): transport 翻译为 `TransientError` → RetryMW 指数退避+jitter(取 Retry-After 提示与退避的较大值)后换源重试;每次尝试独立 call_id、独立过限流闸、失败即报熔断计数与遥测。
|
||||||
4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。
|
4. **源死亡**(401/403/欠费): `SourceDeadError` → 该源熔断 force_open + 本地冷却备忘 → 立即换下一源,不退避等待。
|
||||||
5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。
|
5. **请求被拒**(400/坏输入): `RequestRejectedError` → 不重试不换源,直接上抛;遥测记录。
|
||||||
@@ -322,13 +322,25 @@ flowchart TB
|
|||||||
| `content` | str | 正式输出文本 |
|
| `content` | str | 正式输出文本 |
|
||||||
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
|
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
|
||||||
| `model` / `provider` | str | 溯源 |
|
| `model` / `provider` | str | 溯源 |
|
||||||
| `prompt_tokens` / `completion_tokens` | int | usage 帧读取;缺失时按估算标注 |
|
| `prompt_tokens` / `completion_tokens` | int | usage 帧读取;帧缺失时记 `0/0` 并由 `usage_source` 标注不可得(不编造估算值,见下) |
|
||||||
| `latency_ms` | int | 总延迟 |
|
| `latency_ms` | int | 总延迟 |
|
||||||
| `ttft_ms` / `max_inter_token_ms` | float? | 流式活性测量 |
|
| `ttft_ms` / `max_inter_token_ms` | float? | 流式活性测量 |
|
||||||
| `cache_hit` | bool | 是否缓存命中 |
|
| `cache_hit` | bool | 是否缓存命中 |
|
||||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||||
|
|
||||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(measured/estimated)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
|
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
|
||||||
|
|
||||||
|
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||||
|
|
||||||
|
| 值 | 含义 | 生产者 | cost |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
||||||
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
||||||
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
||||||
|
|
||||||
|
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
||||||
|
|
||||||
|
**cost 口径的不变式**: **产生了真实网关调用、但用量不可得的行 → `cost` 为 NULL**(不再算出一个假的 `0.0` 把"免费"与"未知"混为一谈)。**缓存命中行不在此列**——`cache_hit=True` 时 cost 仍为 `0.0`,因为未产生新调用,`0.0` 是事实而非未知;`TelemetryEmitter` 里 `unavailable → None` 的短路**插在 `cache_hit` 分支之后**正是为此。故账目缺口的度量口径必须写成 `WHERE usage_source = 'unavailable' AND cache_hit = false`,漏掉后半个条件会把本无缺口的缓存命中行灌进来,度量偏高。
|
||||||
|
|
||||||
**API 稳定性约定(2026-07-20,迁移文档反向约束)**: ① 公共类型新增字段必须带默认值——三项目测试中逐字段传参的 fake 构造才能零改动;② 错误四分类从 `polygateway` 顶层命名空间导出——业务侧步级重试要引用它们(GovDoc/Video-Tree 现有 `(TimeoutError, OSError)` 异常元组迁移后会**静默失效**,必须显式替换为库异常);③ `GatewayClient` 提供显式 `aclose()` 与 async context manager 生命周期 API;④ 被取消的调用尽力而为记遥测(error="cancelled",finally 中记录,绝不因遥测延迟取消传播,写失败静默)。
|
**API 稳定性约定(2026-07-20,迁移文档反向约束)**: ① 公共类型新增字段必须带默认值——三项目测试中逐字段传参的 fake 构造才能零改动;② 错误四分类从 `polygateway` 顶层命名空间导出——业务侧步级重试要引用它们(GovDoc/Video-Tree 现有 `(TimeoutError, OSError)` 异常元组迁移后会**静默失效**,必须显式替换为库异常);③ `GatewayClient` 提供显式 `aclose()` 与 async context manager 生命周期 API;④ 被取消的调用尽力而为记遥测(error="cancelled",finally 中记录,绝不因遥测延迟取消传播,写失败静默)。
|
||||||
|
|
||||||
@@ -381,7 +393,7 @@ flowchart TB
|
|||||||
|
|
||||||
**职责**: 一次原始调用的全部协议细节——请求体组装(含 provider 注册表注入的 thinking 参数)、发送、流式 SSE 解析(增量 content/reasoning_content、usage 帧、[DONE] 检测)、HTTP/线路错误按 §6.2 翻译。**不含**重试/限流/缓存(那是中间件的事)。
|
**职责**: 一次原始调用的全部协议细节——请求体组装(含 provider 注册表注入的 thinking 参数)、发送、流式 SSE 解析(增量 content/reasoning_content、usage 帧、[DONE] 检测)、HTTP/线路错误按 §6.2 翻译。**不含**重试/限流/缓存(那是中间件的事)。
|
||||||
|
|
||||||
- `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径强制 `usage_source="estimated"`。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
|
- `OpenAICompatTransport`(默认): httpx.AsyncClient(每源一个,预配 Authorization 与分段超时),SSE 解析移植三项目的模块级纯函数;强制 `stream_options.include_usage`。**SSE 缺 [DONE] 语义(2026-07-20 M1 设计)**: per-source `missing_done: "retry" | "salvage"`,默认 retry(防截断响应进缓存被固化);零内容提前断流(early_eof)恒 retry 不可配;打捞路径**仅在收到 usage 帧时**把 `measured` 降级为 `estimated`(**勘误 2026-07-30**: 原文"强制 estimated" 已改为有条件——没收到 usage 帧时用量本就是 `unavailable`,强制标 `estimated` 会让 `0/0` 被当作实测数字换算出一个假的 `0.0` 成本,§5.1)。CHS 迁移配 salvage 保留其现状行为。看门狗活性口径: 任何增量(content 或 reasoning_content)都算 token——ttft = 首个任意 token,思考流刷新 inter_token 计时(CHS 迁移约束 R1)。**非流式快路径**: 短请求可配 `stream=False`(三项目都写死 stream=True 强迫短请求走 SSE+看门狗,库放开)。
|
||||||
- `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。
|
- `OpenAISDKTransport`(可选 extra): 薄封装,`max_retries=0` 关掉 SDK 自带重试(治理归中间件),`extra_body`/`model_extra` 通道非标字段。
|
||||||
- `MonkeyOcrTransport`: 见 §7.10。
|
- `MonkeyOcrTransport`: 见 §7.10。
|
||||||
|
|
||||||
@@ -425,7 +437,13 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.7 多源与选源
|
### 7.7 多源与选源
|
||||||
|
|
||||||
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣常量,亦作 usage 缺失时的保守兜底,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。
|
`SourceConfig`: name/provider/base_url/api_key/model/超时组/限额组(单源并发/RPM/TPM)/`est_tokens`(TPM 预扣量的**可选调优覆盖**,移植 CHS `config.py:55`;2026-07-20 缺口 G2 补,2026-07-30 由必填降为可选)/enable_thinking。聚合自环境变量 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(§9)。
|
||||||
|
|
||||||
|
**TPM 有效预扣量(2026-07-30,est_tokens 解耦设计,G2 闭环)**: `try_acquire`(§7.3)传入的 est 来自 `SourceConfig.effective_est_tokens()` 这一份纯方法,五个调用点(`QuotaGate` 入场 + chat/embedding 各自的成功侧与失败侧结算)共用,保证预扣与结算恒取同一值(`delta == 0`,否则押金会被整笔退回、TPM 闸退化成进门即放行)。规则:显式 `est_tokens > 0` 则原样用;否则 `tpm > 0` 时派生 `max(1, tpm // 60)`;`tpm == 0`(该闸不启用)时为 0。
|
||||||
|
|
||||||
|
派生取 `tpm // 60` 的理由是**尺度无关**:任何配额规模都给出同一行为上限——"一次调用约占一秒钟的配额份额",故 `tpm=6000` 与 `tpm=600000` 都收敛到约 60 个在途。固定常量(如 1000)则与配额规模无关,在途上限随配额乱飘且取值无从解释。`est_tokens` **不再兼任 usage 缺失时的用量兜底**:那两份差事对"保守"的定义方向相反——限流语境下押多了只是慢(安全),计费语境下按上界记账只会系统性虚高(库把遥测拆成 prompt/completion 两列后又整块塞进 completion,而输出单价通常是输入的数倍,实测双重高估约 26 倍)。用量不可得现在如实记 `unavailable` + cost NULL(§5.1)。
|
||||||
|
|
||||||
|
**已知限制(既有行为,本次未修)**: 单源 `tpm == 0` 而 `{SCOPE}__GLOBAL__TPM > 0` 时,派生值为 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。修它需要把 `GlobalLimits` 注入 `QuotaGate`(改三处装配),属独立议题。`SourceSelector` 端口: `health_aware`(M2.5 新缺省: 成功率 EWMA / (1+在途) 的 P2C,0.05 探索地板,进程本地健康态,可选 `OutcomeAwareSelector` 扩展喂数)/ `round_robin` / `least_inflight`。**逻辑角色**: Video-Tree 式 SEARCH/JUDGE/VL/EVOLVE 多角色 = 命名的 client 配置组,`from_env()` 支持按角色前缀装配多个 client;禁止两个角色静默共享同一实例却在配置上看似独立(Video-Tree `evolve_llm = llm` 别名的教训——共享必须显式)。
|
||||||
|
|
||||||
**多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5)**: 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client **显式注入同一个状态后端实例**时即共享全局并发/RPM/TPM 闸(Video-Tree `TREE_BUILD_API_CONCURRENCY` 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。
|
**多 client 共享状态后端(2026-07-20,VT 迁移缺口 R5)**: 限流/熔断状态的 key 以 scope+source 为单位,与 client 实例解耦;多个逻辑角色的 client **显式注入同一个状态后端实例**时即共享全局并发/RPM/TPM 闸(Video-Tree `TREE_BUILD_API_CONCURRENCY` 跨 SEARCH+VL 共享 semaphore 的语义由此承接)。共享必须显式注入,禁止隐式全局。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# est_tokens 解耦设计(issue #2)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-30
|
||||||
|
- **触发**: Gitea issue #2《est_tokens 应由库按实测自估,而不是让调用方填一个没有正确取值的常量》
|
||||||
|
- **档位**: 强制档(改公共 API 语义 + `usage_source` 公共值域 + 推翻一条已声明保留的迁移行为)→ 需人类审批门
|
||||||
|
- **修订的权威文档**(经独立审查补全):
|
||||||
|
- `ARCHITECTURE.md` §7.7 行 428(`SourceConfig.est_tokens` 描述)、§5.1 行 331(`usage_source` 值域)、**§4.4 行 305**("token 按 `est_tokens` 预扣")、**§7.1 行 384**("打捞路径强制 `usage_source="estimated"`",因 §3.2 #4 变为有条件)
|
||||||
|
- `migrations/chsanalyzer.md` 行 151 与 G2(行 185)
|
||||||
|
- **`.env.example` 行 11**("TPM > 0 时 EST_TOKENS 必填 > 0",约束已废除)
|
||||||
|
|
||||||
|
## 1. 问题:一个常量被派了两份互相矛盾的差事
|
||||||
|
|
||||||
|
`SourceConfig.est_tokens` 同时承担两个职责,而两者对"保守"的定义方向相反:
|
||||||
|
|
||||||
|
| 职责 | 语境 | "保守"意味着 | 填大的后果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TPM 入场预扣 | 限流 | 多押金,宁可压吞吐也不击穿网关 | 安全(只是慢) |
|
||||||
|
| usage 缺失时的用量兜底 | 计费 | **不存在保守方向** | 账单虚高 |
|
||||||
|
|
||||||
|
CHS 原版 `config.py:55` 把它定义为"须 ≥ 最坏情形 token"——按定义是**上界**。拿上界当实测值记账,必然系统性高估。库把遥测拆成 `prompt_tokens`/`completion_tokens` 两列后又把整个估值塞进 `completion`(`openai_compat.py:146`),而 `pricing.py:70-72` 按 `prompt×input价 + completion×output价` 换算,输出单价通常是输入的数倍——**双重高估**。
|
||||||
|
|
||||||
|
实测算例:`est_tokens=4000`,单价输入 1 元/百万、输出 8 元/百万,真实消耗 400+100:
|
||||||
|
|
||||||
|
| | 记账 token | cost |
|
||||||
|
|---|---|---|
|
||||||
|
| 真实 | 400 / 100 | 0.0012 元 |
|
||||||
|
| 现状 | 0 / 4000 | 0.032 元(**26 倍**) |
|
||||||
|
|
||||||
|
第二个症状是装配约束:`types.py:125` 的 `tpm > 0 ⇒ est_tokens > 0` 把供应商配额(运维可从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。下游 CHSAnalyzer 删掉 `est_tokens` 配置项后,`tpm` 就再也不能填非 0,只能在自己的配置模型里把 `tpm` 限死为 0 绕开——库把内部细节泄漏进了配置面。
|
||||||
|
|
||||||
|
## 2. 备选方案对比
|
||||||
|
|
||||||
|
### 2.1 决策点一:usage 不可得时遥测记什么
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A(选定)** | 记 `0/0`,`usage_source` 扩一个 `unavailable`,cost 记 NULL | 缺数据可被统计:`SUM(cost)` 跳过 NULL,`COUNT(*) WHERE usage_source='unavailable' AND cache_hit = false` 能量化账的缺口(**必须带 `cache_hit` 限定**:按 §3.2 #5 的裁决,缓存命中行可以既是 `unavailable` 又有 `cost=0.0`,它们本无账目缺口,不加限定就会灌水——与 §3.3 剔出 OCR 用的是同一把尺子)。代价:公共值域变更,需进 CHANGELOG,且该查询口径要一并写进 wiki(§8) |
|
||||||
|
| B | 记 `0/0`,沿用 `estimated` | 改动最小(等于把 `est_tokens>0` 路径统一到 `est_tokens=0` 的现状行为)。**否决**:cost 算出 `0.0`,"免费"与"未知"在数据上不可区分,缺口不可量化 |
|
||||||
|
| C | 保留 est 兜底,只修 `prompt`/`completion` 分配比例 | 保住 CHS"保守计量"意图。**否决**:比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"这个根因,仍高估约 9 倍 |
|
||||||
|
|
||||||
|
### 2.2 决策点二:`est_tokens` 未填时的默认预扣量
|
||||||
|
|
||||||
|
先排除"不预扣":`try_acquire` 传 0 会让 TPM 窗口在请求飞出到 settle 回来的整段时间形同虚设,大批请求可同时入场,正是"防击穿网关"要防的场景,与 CLAUDE.md 降级方向铁律相悖。
|
||||||
|
|
||||||
|
| 方案 | 源甲 `tpm=6000` | 源乙 `tpm=600000` | 权衡 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **派生 `tpm//60`(选定)** | 押 100 → 60 个在途 | 押 10000 → 60 个在途 | 尺度无关:任何配额规模都给出同一行为上限,语义可写进 docstring("一次调用约占一秒钟的配额份额") |
|
||||||
|
| 固定常量 1000 | 押金占配额 1/6 → 仅 6 个在途,小请求场景白慢数倍 | 押金占 1/600 → 600 个在途,大请求场景照样撞 429 | **否决**:常量与配额规模无关,在途上限随配额乱飘,无法解释取值 |
|
||||||
|
|
||||||
|
### 2.3 派生逻辑的落点
|
||||||
|
|
||||||
|
| 方案 | 权衡 |
|
||||||
|
|---|---|
|
||||||
|
| **`SourceConfig.effective_est_tokens()`(选定)** | 纯方法只读自身字段,落 `types.py` 内核不违反依赖铁律;零装配变更、零端口变更;5 个调用点(`QuotaGate` 入场 + retry/embedding 各自的成功侧与失败侧结算)共用一份 |
|
||||||
|
| 注入 `GlobalLimits` 到 `QuotaGate`,派生取全局与单源 tpm 的较紧者 | 能覆盖"单源 `tpm=0` 而全局 `tpm>0`"的场景。**否决**:需改三处装配(`retry.py:186`/`embedding.py:116`/`ocr.py:119`),且它修的是一个**既有**缺口(见 §7),超出本任务范围 |
|
||||||
|
| 派生下沉到两个 limiter 后端 | **否决**:`try_acquire(source_key, est_tokens)` 的入参会变成谎言(后端忽略它),且逻辑要写两遍,违反 D3"语义契约只有一份"与 P7"决策与存储分离" |
|
||||||
|
|
||||||
|
## 3. 选定方案
|
||||||
|
|
||||||
|
### 3.1 `usage_source` 三态值域
|
||||||
|
|
||||||
|
| 值 | 含义 | 生产者 | cost |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `measured` | usage 帧完整可信 | 正常路径 | 按 token 换算 |
|
||||||
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
||||||
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL**(缓存命中行例外,见 §3.2 #5) |
|
||||||
|
|
||||||
|
`estimated` 保留且有真实生产者(打捞),同时保证历史库里既有的 `estimated` 行读兼容。
|
||||||
|
|
||||||
|
**不变式的准确表述**: 产生了真实网关调用、但用量不可得的行 → cost 为 NULL。缓存命中行不在此列(见 §3.2 #5)。
|
||||||
|
|
||||||
|
**值域的强制落点**: `types.py` 模块级 frozenset 常量,仅约束**库内生产侧**——所有写入 `usage_source` 的位置从该常量取值,测试断言库内产出恒在三态内。**不在 `LLMResponse`/`Usage`/`TransportResult` 等 frozen dataclass 上加 `__post_init__` 值域校验**,两条理由:① 它们是运行时构造点(如 `retry.py:418`),裸 `ValueError` 不属 `errors.py` 四分类,`RetryMW` 不捕它,会直接逃出 `chat()`,违反错误分类驱动铁律;② `LLMResponse` 是三项目已消费的公共类型,新增运行时校验是下游可见行为变更,超出本任务。故 §6 的值域测试断言"库内所有生产点的产出值落在三态内",而非"越界字符串被拒"。
|
||||||
|
|
||||||
|
### 3.2 逐处改动
|
||||||
|
|
||||||
|
| # | 位置 | 改动 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `types.py:125` | 删除 `tpm > 0 ⇒ est_tokens > 0`;`est_tokens` 保留字段、语义降为"可选调优覆盖" |
|
||||||
|
| 2 | `types.py` `SourceConfig` | 新增 `effective_est_tokens()`:显式值 > 0 则原样返回;否则 `tpm > 0` 时返回 `max(1, tpm // 60)`,`tpm == 0` 时返回 0 |
|
||||||
|
| 3 | `openai_compat.py:146,176` | 两处兜底改为 `(0, 0, "unavailable")` / `(0, "unavailable")`,不再读 `source.est_tokens` |
|
||||||
|
| 4 | `openai_compat.py:336` | 打捞覆盖加条件:仅当 `usage_source == "measured"` 时降级为 `estimated`,否则保持 `unavailable`(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`) |
|
||||||
|
| 5 | `middleware/telemetry.py:130-135` | cost 分支增加短路:`usage_source == "unavailable"` → `None`。**插在 `cache_hit` 分支之后**:缓存命中未产生新调用,`0.0` 是事实而非未知,既有"缓存命中 0.0"语义保持不动。故 `cache_hit=True` 且 `usage_source="unavailable"` 的行 cost 仍是 `0.0`,与 §3.1 不变式不冲突(那条只管产生了真实调用的行) |
|
||||||
|
| 6 | `middleware/telemetry.py:58,100` | 失败尝试与终态失败的 `usage_source` 由 `estimated` 改 `unavailable`(用量确实不可得;这两行 cost 本已是 None,语义对齐不改金额) |
|
||||||
|
| 7 | `middleware/ratelimit.py:26` | `source.est_tokens` → `source.effective_est_tokens()` |
|
||||||
|
| 8 | `retry.py:370`、`embedding.py:294` | **失败侧**保守结算改用 `effective_est_tokens()`。必须同改:预扣派生值而结算退 `est_tokens=0` 会让 `delta` 为负、退掉全部押金,丢掉"失败可能已被计费"的保守意图 |
|
||||||
|
| 9 | `retry.py:338`、`embedding.py:271` | **成功侧**结算:`usage_source == "unavailable"` 时按 `effective_est_tokens()` 结算,而非 `prompt+completion`(此时恒为 0)。**这条是保持既有行为、不是新增保守**:改前 `_resolve_usage` 恰好返回 `est_tokens`,使 `actual == 预扣量`、`delta == 0`、押金留存;#3 把它改成 `(0, 0)` 后若不同改,成功调用的押金会被整笔退回,对"从不返回 usage 帧的网关源"构成系统性 TPM 计量失效——闸门退化成进门即放行、出门即清账,正是降级方向铁律要防的击穿 |
|
||||||
|
| 10 | `embedding.py:383,390` | 二值合并扩为三态:任一批 `unavailable` → 整体 `unavailable`;否则任一 `estimated` → `estimated`;否则 `measured`。同步更新 `types.py:273` 的行内注释 `# measured | estimated`,内核里不留与三态矛盾的注释 |
|
||||||
|
| 11 | `embedding.py:397` `_total_cost` | 存在 `unavailable` 批时整体 cost 记 NULL(逐批求和会给出一个偏低却看似有效的金额) |
|
||||||
|
|
||||||
|
### 3.3 明确不改的
|
||||||
|
|
||||||
|
**非 dead 的瞬时失败路径**(`retry.py:369` 的 `if not dead` 分支)按预扣量做**限流**结算的行为保留——那是限流语境,保守方向正确(失败请求可能已被网关计费),且该值只流向 `_settle_and_release`,不进遥测。其余三条失败分支(`RequestRejectedError`/`ResultInvalidError`/`SourceDeadError`)的 `actual` 停在初值 0(`retry.py:329`),属既有行为,本次**不动**——#8 已把行号钉死,实现时不要顺手把这三条也改成保守结算。`SourceConfig.est_tokens` 字段与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键**保留不删不改名**(迁移兼容硬约束,ARCHITECTURE §5.1)。`RateLimiter` 端口签名不变。
|
||||||
|
|
||||||
|
**`ocr.py:411` 的 `usage_source="measured"` 保留不改**(初稿曾列为改动项,独立审查后剔出)。库既有立场是 OCR 的 0 token 属**事实**而非未知——`types.py:51` "token 用量;OCR 等无计费调用填 0"、`ocr.py:9` "settle 恒为 0(OCR 无 token 计费)"——故 `measured` 是准确陈述。改成 `unavailable` 还会反噬 §2.1 的核心度量:`COUNT(*) WHERE usage_source='unavailable'` 本用于量化账目缺口,灌进本无缺口的 OCR 行就失去意义。
|
||||||
|
|
||||||
|
## 4. 旧版行为审计(迁移保留项的推翻声明)
|
||||||
|
|
||||||
|
| 旧版行为 | 出处 | 本次处置 |
|
||||||
|
|---|---|---|
|
||||||
|
| usage 缺失按 `est_tokens` 估算并标 `estimated`,不静默用 0 | CHS `invokers.py:241-254`;`migrations/chsanalyzer.md:151` 标记为**保留** | **有意放弃**。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆两列后无法忠实分配,且 `est_tokens` 按 CHS 自身定义是最坏情形上界。"保守"在限流语境安全、在计费语境只有错误一个方向 |
|
||||||
|
| 缺失时不静默用 0(拒绝 VT 的"填 0 且不标注") | 同上;`m1-core-design.md:222` 行 10 | **保留**。本方案记 0 但带 `unavailable` 显式标记且 cost 为 NULL,反静默的原始意图完整保留——被放弃的只是"编一个数字"这个手段 |
|
||||||
|
| `est_tokens` 作 TPM 入场预扣常量 | CHS `config.py:55` | **保留**,仅由必填降为可选覆盖 |
|
||||||
|
| `tpm > 0 ⇒ est_tokens > 0` 装配校验 | `m1-core-plan.md:93` | **替换**为库内派生,校验删除 |
|
||||||
|
| 打捞路径强制 `estimated` | `m1-core-design.md` §6 | **保留**,补一个前置条件(§3.2 #4) |
|
||||||
|
| 遥测 `INSERT OR IGNORE` 幂等、写失败降级不冒泡、列只增 | `m1-core-design.md:218` | **保留**,本次无 DDL 变更 |
|
||||||
|
|
||||||
|
## 5. 非功能维度
|
||||||
|
|
||||||
|
**并发与取消**: `effective_est_tokens()` 是无状态纯方法(只读 frozen dataclass 字段),并发安全、无锁、可重复调用。本次改动不新增 `await` 点、不改变任何 `try/finally` 结构,取消穿透路径与 in-flight 释放语义原样不动。#8 与 #9 合起来保证**成功侧与非 dead 瞬时失败侧**的预扣与结算恒取同一派生值(`delta == 0`)——这是本设计里最容易漏的一致性约束(初稿只写了失败侧,独立审查发现成功侧缺口)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧不在此列**:它们的 `actual` 停在 `retry.py:329` 的初值 0、全额退回,属 §3.3 声明不动的既有行为。
|
||||||
|
|
||||||
|
**降级方向**: 不改变任何后端的降级方向。遥测侧仍是静默降级(`telemetry.py:161` 的 warning 不冒泡);限流侧仍是 `GovernanceBackendError` 上抛而非放行;TPM 计量不因 usage 帧缺失而静默失效(#9)。
|
||||||
|
|
||||||
|
否决 issue 建议的 p90 自估,主论据是 **`TelemetryRecorder` 目前是纯只写端口,自估需要新增读接口并强制所有后端(含 `none`)实现**,公共 API 扩张远大于它要省掉的一个可选字段,且尚无实测证据表明派生默认值不够用(§8)。初稿曾论证"那会把两条方向相反的降级铁律焊在一起",此论据经审查后**撤回**:p90 方案完全可以在遥测读失败时回退到纯派生值,限流侧仍能保持 fail-closed,故并非必然冲突。结论不变,理由收窄。
|
||||||
|
|
||||||
|
**幂等与重复**: `Permit.settle()`/`release()` 的幂等 flag 语义不变。#8 与 #9 使预扣与结算取自同一派生函数,同一请求重复结算仍是 no-op。
|
||||||
|
|
||||||
|
**持久化与原子性**: 无 DDL 变更(两 schema 的 `cost` 列已可空);无新增落盘点;Redis Lua 脚本不改(仍接收调用方算好的 est)。历史数据不迁移:旧行的 `estimated` 语义在新值域中依然合法可读。
|
||||||
|
|
||||||
|
## 6. 错误处理与测试策略
|
||||||
|
|
||||||
|
值域校验失败属配置/内部不变量违反 → `ValueError`(装配期 fail-loud),不进四分类运行时错误。本次不改变任何调用失败的分类归属。
|
||||||
|
|
||||||
|
| 测试 | 断言要点 | 文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 约束解绑 | `tpm=6000, est_tokens=0` 构造成功(改前抛 ValueError) | `tests/unit/test_types.py` |
|
||||||
|
| 派生尺度无关 | `tpm=6000→100`、`tpm=600000→10000`、`tpm=0→0`、显式值优先、`tpm=30→max(1,·)` 不为 0 | 同上 |
|
||||||
|
| cost 不再造假 | `est_tokens=4000` + usage 缺失 → `0/0/unavailable` 且 `record_llm_call` 收到 `cost=None`(改前 `0.032`) | `tests/unit/test_openai_compat.py`、`test_telemetry.py` |
|
||||||
|
| 缓存命中不受牵连 | `cache_hit=True` 且 `unavailable` → cost 仍为 `0.0`(锁定 §3.2 #5 的分支次序) | `test_telemetry.py` |
|
||||||
|
| 打捞前置条件 | 打捞 + usage 帧存在 → `estimated` 且 cost 非 None;打捞 + usage 缺失 → `unavailable` 且 cost 为 None(回归 §3.2 #4) | `test_openai_compat.py` |
|
||||||
|
| **失败侧**结算不退多 | 未填 `est_tokens` 且 `tpm>0` 时失败请求,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #8) | `tests/contracts/test_limiter_contract.py` |
|
||||||
|
| **成功侧**结算不退多 | usage 缺失的**成功**调用后,TPM 窗口残留量等于派生预扣量而非 0(回归 §3.2 #9,本设计最易漏的一条)。现有锚点 `test_retry.py:149` 的 `_src("a", tpm=1000, est_tokens=400)` 旁加一个 `est_tokens=0` + usage 缺失的用例 | `tests/unit/test_retry.py`、`test_limiter_contract.py` |
|
||||||
|
| 三态合并 | 混合批 `measured+unavailable` → 整体 `unavailable` 且 cost 为 NULL | `tests/unit/test_embedding.py` |
|
||||||
|
| OCR 不变 | OCR 成功行仍为 `measured` 且 settle 恒 0(防回归,锁定 §3.3 的剔出决定) | `tests/unit/test_ocr_client.py` |
|
||||||
|
| 值域封闭 | 库内所有生产点的产出恒落在三态内;公共 dataclass 不因越界值抛异常(锁定 §3.1 的落点决定) | `test_types.py` |
|
||||||
|
|
||||||
|
限流侧断言随 `tests/contracts/test_limiter_contract.py` 同时覆盖内存与 Redis 两后端(Redis 走真实实例,遵守共享后端不并跑纪律)。
|
||||||
|
|
||||||
|
## 7. 已知限制(本次不修,显式声明)
|
||||||
|
|
||||||
|
单源 `tpm == 0` 而全局 `tpm > 0` 时,`effective_est_tokens()` 返回 0,全局 TPM 闸拿 0 预扣、入场保护形同虚设。**这是既有行为**(现状约束只管 `cfg.tpm > 0`,该场景下 `est_tokens=0` 本就合法),本方案不引入也不修复它。修它需要把 `GlobalLimits` 注入 `QuotaGate`(§2.3 备选二),属独立议题,建议另开 issue。
|
||||||
|
|
||||||
|
## 8. 下游影响与发布
|
||||||
|
|
||||||
|
`est_tokens` 从必填降为可选后,CHSAnalyzer 可删掉"`tpm` 必须为 0"的绕行校验并填真实 TPM。`usage_source` 出现第三个值、且不可得行的 cost 由数值变 NULL,是下游可见的行为变更:成本汇总若此前依赖"cost 非空"隐含假设需复核。按 `docs-convention.md` §2,发版须同步 CHANGELOG 与 wiki 的 usage/成本口径说明,并在 issue #2 回帖结论。
|
||||||
|
|
||||||
|
遥测驱动的自适应预估(issue 原建议)不在本次范围,待默认派生值在真实负载下出现实测问题后再评估。
|
||||||
|
|
||||||
|
## 9. 规模判定
|
||||||
|
|
||||||
|
改动面(独立审查后重算):**6 个源文件**(`types.py`、`transports/openai_compat.py`、`middleware/telemetry.py`、`middleware/ratelimit.py`、`middleware/retry.py`、`embedding.py`;`ocr.py` 已剔出)、**7 个测试文件**、**3 份权威文档**(ARCHITECTURE.md、`migrations/chsanalyzer.md`、`.env.example`),外加按 `docs-convention.md` §2 必须同步的 CHANGELOG 与用户文档站 wiki(版本 bump 不得裸发)。
|
||||||
|
|
||||||
|
属跨多文件功能 → 本设计经人类审批后须走 `writing-plans` 出实施计划,不得直接进实现。
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-30;**状态**: **已批准并实施**(2026-07-30 人类门通过;§9 结论、§10 实施留痕)
|
||||||
|
- **缘起**: [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md) §9.1 —— 独立 verifier 在第一轮交付后发现,`from_env` 上还留着一批同族校验;本设计是那一轮的续作,**同一个 bug 类的剩余部分**
|
||||||
|
- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路)
|
||||||
|
|
||||||
|
## 1. 待收拢的校验清单(逐条实测确认只在 `from_env` 生效)
|
||||||
|
|
||||||
|
### A. 枚举合法域(6 条)
|
||||||
|
|
||||||
|
| 字段 | 合法域 | 现居 |
|
||||||
|
|---|---|---|
|
||||||
|
| `limiter_backend` / `breaker_backend` | `{memory, redis}` | `_load_pgw`(经 `_load_choice`) |
|
||||||
|
| `cache_backend` | `{redis, memory, none}` | `_load_pgw` 内联 |
|
||||||
|
| `telemetry_backend` | `{sqlite, postgres, none}` | `_load_pgw` 内联 |
|
||||||
|
| `selector` | `_SELECTORS` | `from_env` 调 `_load_choice` |
|
||||||
|
| `quota_full` | `_QUOTA_FULL` | `from_env` 调 `_load_choice` |
|
||||||
|
|
||||||
|
直接构造传 `selector="random"` 或 `cache_backend="rediss"` 一律放行,后果是装配时落进 `_build_*` 的 else 分支或静默不建后端。
|
||||||
|
|
||||||
|
### B. 条件必填(7 条,跨字段)
|
||||||
|
|
||||||
|
| 条件 | 要求 | 违反后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| `limiter_backend`/`breaker_backend`/`cache_backend` 取 `redis` | `redis_url` 非空 | **见 §2**,最严重 |
|
||||||
|
| `cache_backend != "none"` | `cache_namespace` 非空 | 缓存 key 失去租户隔离——踩"无缓存毒化"铁律 |
|
||||||
|
| `cache_backend != "none"` | `cache_ttl_s > 0` | `from_env` 明令禁止的"永不过期"从另一条路进来 |
|
||||||
|
| `telemetry_backend == "sqlite"` | `telemetry_sqlite_path` 非空 | 断言炸或写空路径 |
|
||||||
|
| `telemetry_backend == "postgres"` | `telemetry_pg_dsn` 非空 | 同上 |
|
||||||
|
|
||||||
|
### C. 标量域(2 条)
|
||||||
|
|
||||||
|
`structured_max_retries ≥ 0`;`scope` 非空(空 scope 会污染遥测与缓存命名空间)。
|
||||||
|
|
||||||
|
## 2. 为什么这批比第一轮更严重:`client.py` 的断言前提为假
|
||||||
|
|
||||||
|
`client.py` 有 5 处断言**明文声称这个前提已经成立**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert settings.redis_url is not None # 内部不变量: config 已校验
|
||||||
|
```
|
||||||
|
|
||||||
|
位置:`client.py:262/282/302`(redis_url)、`:312`(pg_dsn)、`:316`(sqlite_path)。走 `from_settings` 时该注释是假的,verifier 实测:
|
||||||
|
|
||||||
|
| 运行方式 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 断言开启 | `AssertionError()` —— 裸断言,不点字段、不说原因 |
|
||||||
|
| `python -O` | 断言消失,退化为 redis 库的 `ValueError: Redis URL must specify one of the following schemes...` |
|
||||||
|
|
||||||
|
后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。
|
||||||
|
|
||||||
|
**但注意结论的方向**:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。
|
||||||
|
|
||||||
|
## 3. 方案
|
||||||
|
|
||||||
|
沿用第一轮已批准的方案 A,不重新论证:全部收进 `GatewaySettings.__post_init__`,新增三个私有方法与既有四个并列。
|
||||||
|
|
||||||
|
| 方法 | 覆盖 |
|
||||||
|
|---|---|
|
||||||
|
| `_validate_backends` | A 类 6 条枚举 + B 类 redis_url 三条件 |
|
||||||
|
| `_validate_cache` | `cache_namespace` 非空、`cache_ttl_s > 0`(仅 `cache_backend != "none"` 时) |
|
||||||
|
| `_validate_telemetry` | sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 |
|
||||||
|
|
||||||
|
标量两条(`structured_max_retries`、`scope`)并入 `_validate_sources` 改名后的 `_validate_identity`,与 `SourceConfig._validate_identity` 同名同职。
|
||||||
|
|
||||||
|
枚举合法域上提为模块级 frozenset 常量(`_LIMITER_BACKENDS` 等),`_load_pgw` 与 `__post_init__` 共用一份,消除现有的内联字面量重复。
|
||||||
|
|
||||||
|
**否决的替代**:在 `_build_limiter`/`_build_cache` 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 `dataclasses.replace` 仍绕过。
|
||||||
|
|
||||||
|
## 4. 5 处 assert 的处置:**保留,不改**
|
||||||
|
|
||||||
|
修好构造期校验后,`settings.redis_url is not None` 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 `str | None`。此时删掉 assert 反而丢失类型信息,改成 `raise` 则是在防御一个已被构造期排除的情况(死代码)。
|
||||||
|
|
||||||
|
**要改的是注释**:`# 内部不变量: config 已校验` 应点明由谁保证,例如 `# 内部不变量: GatewaySettings._validate_backends 已保证`。前一轮的教训就是这类注释会随时间变成谎言。
|
||||||
|
|
||||||
|
## 5. Postgres DSN:校验而非规范化(本轮唯一的新决策)
|
||||||
|
|
||||||
|
`_load_pg_dsn` 对 `from_env` 读到的 DSN 做了**规范化**:剥掉 SQLAlchemy 风格的 `+asyncpg` 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,`postgresql+asyncpg://...` 会原样送进 asyncpg 然后在首次写遥测时才炸。
|
||||||
|
|
||||||
|
| 选项 | 权衡 |
|
||||||
|
|---|---|
|
||||||
|
| A. 构造期校验,含 `+driver` 即报错 | 显式,库不碰用户给的值;但两条装配路对同一输入接受度不同 |
|
||||||
|
| B. 构造期静默剥后缀 | 两条路完全对齐;但 frozen 类在构造期悄悄改字段,调用方不知情 |
|
||||||
|
| **C. 构造期剥后缀 + `logger.warning`(用户 2026-07-30 拍板)** | 两条路行为对齐,同时不静默——调用方在日志里看得见库动了他的值,想根治就自己改 DSN |
|
||||||
|
|
||||||
|
选 C。实现要点:`object.__setattr__` 改 frozen 字段(`SourceConfig` 无此先例,但 frozen 的约束是对**外部**不可变,构造期规范化是既有 dataclass 惯用法);warning 走 loguru(核心依赖,库内 `ocr.py:183`/`embedding.py:318` 同款用法)。
|
||||||
|
|
||||||
|
**warning 不会打扰 env 用户**:`_load_pg_dsn` 保留现有的剥离逻辑,`from_env` 传给构造函数时 DSN 已经干净,`__post_init__` 无事可做。只有手工构造传了带后缀的 DSN 才会触发。三项目 `.env` 里那些 SQLAlchemy 写法不会每次装配刷一条 warning。
|
||||||
|
|
||||||
|
代价是同一件事有两处剥离逻辑。用同一个模块级 helper `_strip_dsn_driver(dsn)` 供两处调用,避免实现分叉。
|
||||||
|
|
||||||
|
## 6. 行为审计
|
||||||
|
|
||||||
|
| 现有行为 | 处置 |
|
||||||
|
|---|---|
|
||||||
|
| `from_env` 对上述 15 条的校验与报错 | **全部保留**,时机提前到 `cls(...)`;`_load_*` 内联检查删除,避免同一约束两处维护 |
|
||||||
|
| `_load_pg_dsn` 剥 `+driver` | **保留**,继续只在 env 路径生效(§5) |
|
||||||
|
| `_load_choice` 的 `default` 语义(键缺失时取默认) | **保留**,那是 env 解析职责,不是不变量 |
|
||||||
|
| `_load_breaker` 的有效阈值派生 `max(配置值, 源级并发×2)` | **有意保留在 env 层**(verifier 二次核验点名,记此备案免成"第五批")。它是**派生**不是校验/规范化:两路产出确实不同(env 装配 threshold=5/并发=100 得 200,直接构造得 5),但派生依赖的是"用户没显式表态时库替他选一个合理值"的 env 语义;代码构造那条路,调用方给什么就是什么表态。其跨字段下限风险由 `_validate_probe` 在构造期兜底 |
|
||||||
|
| 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` |
|
||||||
|
| `client.py` 5 处 assert | **保留**,仅改注释(§4) |
|
||||||
|
| 异常类型 | 一律 `ValueError`,与第一轮及既有装配错误一致 |
|
||||||
|
|
||||||
|
**有意放弃**:不校验 `pricing_path` 指向的文件是否存在(I/O 不属于配置校验,`PricingTable.from_file` 自会报错);不强制 `cache_backend == "none"` 时 namespace/ttl 必须为 None(多余字段无害)。
|
||||||
|
|
||||||
|
## 7. 非功能维度
|
||||||
|
|
||||||
|
与第一轮同构,不重复论证:`__post_init__` 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;`__post_init__` 不改字段故幂等。**性能**:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 `GatewaySettings`,可忽略。
|
||||||
|
|
||||||
|
## 8. 测试策略
|
||||||
|
|
||||||
|
`tests/unit/test_config.py::TestCrossFieldInvariants` 扩充(不新建类,同族不变量归一处):
|
||||||
|
|
||||||
|
| 用例组 | 断言 |
|
||||||
|
|---|---|
|
||||||
|
| 6 条枚举各一条非法值 | 抛 `ValueError`,消息含字段名与合法域 |
|
||||||
|
| redis_url 三条件(limiter/breaker/cache 各一) | 抛 `ValueError`,消息点明需要 `redis_url` |
|
||||||
|
| cache namespace 缺失 / ttl ≤ 0 | 抛 `ValueError` |
|
||||||
|
| telemetry sqlite path / pg dsn 缺失 | 抛 `ValueError` |
|
||||||
|
| `structured_max_retries=-1`、`scope=""` | 抛 `ValueError` |
|
||||||
|
| pg dsn 含 `+asyncpg`(直接构造) | 后缀被剥,字段值为干净 DSN,且发出一条 warning(用 `caplog`/loguru sink 断言) |
|
||||||
|
| pg dsn 干净(直接构造)、或经 `from_env` 传入 | **不发** warning——env 路已在 `_load_pg_dsn` 剥过,不该刷噪音 |
|
||||||
|
| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 |
|
||||||
|
| **回归护栏**:`GatewayClient.from_settings` 走 redis 三后端的合法配置 | 装配成功,证明 assert 前提真的被保证了 |
|
||||||
|
|
||||||
|
TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。
|
||||||
|
|
||||||
|
版本:**1.0.2**(patch),CHANGELOG 同样单列"行为收紧"小节。
|
||||||
|
|
||||||
|
## 9. 人类拍板结论(2026-07-30)
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| §5 DSN 处置 | **选 C**:构造期剥后缀 + `logger.warning`。不静默改用户的值,也不让两条装配路产出不一致 |
|
||||||
|
| §4 assert 处置 | **保留,只改注释**,点明由哪个方法保证前提 |
|
||||||
|
| 方案主体 | 沿用第一轮已批准的方案 A,无需重新论证 |
|
||||||
|
| 版本 | 1.0.2(patch) |
|
||||||
|
| **范围追加**(实施中经 verifier 发现后拍板) | G1-G4 四条同族遗漏一并纳入本轮;G1 的 scope 规范化取**静默**小写+strip(不告警——`from_env` 一直静默小写,scope 大小写不承载语义) |
|
||||||
|
|
||||||
|
## 10. 实施留痕
|
||||||
|
|
||||||
|
分支 `fix/settings-invariants-round-2`。TDD 两段:主体 15 条先 **16 failed**、G1-G4 追加 **9 failed**,实现后全绿(547 passed / 14 skipped,1.0.1 基线 516)。
|
||||||
|
|
||||||
|
### 10.1 独立 verifier 的关键发现
|
||||||
|
|
||||||
|
第一次核验判**有阻塞**,已修:
|
||||||
|
|
||||||
|
- **阻塞(本轮新引入)**:DSN 剥离的 warning 打印了完整连接串,**含明文密码**,而库内此前从无任何地方打印连接串——违反 P5。已改为只报 scheme 段变化,并补回归测试断言密码与 host/path 不进日志。
|
||||||
|
- **变异测试 27/28 被杀**,唯一存活的是 `_load_pgw` 里 `PGW_CACHE_BACKEND` 域检查删掉后仍全绿(该 env 层 raise 零覆盖)。已补 `test_cache_backend_whitelist`,与既有 `test_telemetry_backend_whitelist` 对称。
|
||||||
|
- **assert 处置经独立核验成立**:遍历所有可达构造路径均无法制造 assert 失败,`python -O` 下同样在构造期被拦(旧病症消失);唯一能触发的是 `object.__new__` 绕过 `__post_init__` 的人造路径,非公共 API。
|
||||||
|
- **frozen 语义无副作用**:`object.__setattr__` 后 `hash`/相等性/集合去重正常,`replace` 幂等不重复告警,`pickle`/`deepcopy` 不触发 `__post_init__` 故不重复告警,对外仍抛 `FrozenInstanceError`。
|
||||||
|
|
||||||
|
### 10.2 G1-G4:第三批遗漏(已纳入本轮)
|
||||||
|
|
||||||
|
verifier 通读 `_load_*` 后发现,除设计 §1 的 15 条外还有四条**规范化**只在 env 路生效——与本轮所修的 DSN 是同一类:
|
||||||
|
|
||||||
|
| | 内容 | 危害 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | `scope` 小写化 | **最严重**:scope 进 Redis key,大小写不一致使限流/熔断状态分裂到两套命名空间,分布式治理静默失效 |
|
||||||
|
| G2 | `redis_url` 空串归 None | 空串骗过 `is None`,退化为 redis 客户端的连接串天书报错——正是本轮 CHANGELOG 声称已消除的那种 |
|
||||||
|
| G3 | `pricing_path` 空串归 None | 退化为 `Is a directory: '.'` |
|
||||||
|
| G4 | `EmbeddingSettings.batch_size`/`expected_dim` 域 | 该类无 `__post_init__`;晚一步到 client 构造才 fail-loud |
|
||||||
|
|
||||||
|
统一收进新增的 `GatewaySettings._normalize()`(在全部 `_validate_*` 之前跑)与 `EmbeddingSettings.__post_init__`。DSN 后缀因需看 backend 且需告警,规范化留在 `_validate_telemetry`。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:est-tokens-decoupling
|
||||||
|
title: "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)"
|
||||||
|
date: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)
|
||||||
|
|
||||||
|
全文见 [2026-07-30-est-tokens-decoupling-design.md](2026-07-30-est-tokens-decoupling-design.md)。缘起是 Gitea issue #2(下游 CHSAnalyzer 提出)。
|
||||||
|
|
||||||
|
- **缘起**: `SourceConfig.est_tokens` 被派了两份对"保守"定义相反的差事——TPM 入场预扣(多押金 = 安全)与 usage 帧缺失时的遥测用量兜底(计费没有安全方向)。CHS `config.py:55` 自己就把它定义为"须 ≥ 最坏情形 token"的**上界**,而库把遥测拆 `prompt`/`completion` 两列后又将整个估值塞进单价更贵的 `completion`(`openai_compat.py:146`),形成双重系统性高估:实测算例 26 倍。
|
||||||
|
- **第二个症状**: `types.py:125` 的 `tpm > 0 ⇒ est_tokens > 0` 把供应商配额(可从配额页抄)与库的实现细节(无人能正确取值)绑死,下游删掉猜测项后 `tpm` 只能填 0,被迫在自己配置模型里加校验绕开。
|
||||||
|
- **方案(两个决策点,人类审批)**: ① usage 不可得时记 `0/0` + `usage_source` 新增 `unavailable` + cost 记 NULL;② `est_tokens` 未填时由库派生 `tpm // 60`,字段降为可选调优覆盖(不删不改名,迁移兼容)。
|
||||||
|
- **值域三态各有生产者**: `measured`(正常)、`estimated`(打捞路径——收到 usage 帧但流被截断,数字真实而可信度降级)、`unavailable`(用量不可得)。故 `estimated` 不是空值域,历史行亦读兼容。
|
||||||
|
- **被否决 · usage 兜底记 0 但沿用 `estimated`**: cost 会算出 `0.0`,"免费"与"未知"在数据上不可区分,账目缺口无法量化。
|
||||||
|
- **被否决 · 保留 est 兜底只修 prompt/completion 分配比例**: 比例是又一个没有正确取值的魔数,且未触及"拿上界当实测"的根因,仍高估约 9 倍。
|
||||||
|
- **被否决 · 固定默认常量(如 1000)**: 与配额规模无关,在途上限随规模乱飘(`tpm=6000` 只剩 6 个在途、`tpm=600000` 放行 600 个)。派生值尺度无关且语义可文档化("一次调用约占一秒钟的配额份额")。
|
||||||
|
- **被否决 · issue 原建议的遥测 p90 自估**: `TelemetryRecorder` 是纯只写端口,自估需新增读接口并强制所有后端(含 `none`)实现,公共 API 扩张远大于它要省掉的一个可选字段,且无实测证据表明派生默认值不够用。**注**: 初稿曾以"把两条方向相反的降级铁律焊在一起"为主论据,经独立审查撤回——p90 可在遥测读失败时回退纯派生值,限流侧仍能 fail-closed。
|
||||||
|
- **被否决 · 派生逻辑取全局与单源 tpm 的较紧者**: 需改三处 `QuotaGate` 装配,且它修的是一个**既有**缺口(单源 `tpm=0` 而全局 `tpm>0` 时预扣为 0),属任务外,建议另开 issue。
|
||||||
|
- **有意放弃的迁移保留项**: `migrations/chsanalyzer.md:151` 曾把"usage 缺失按 est 估算"列为保留(理由"保守计量")。本设计推翻:CHS 只记单个 `total_tokens` 不存在分配问题,而"保守"在计费语境只有错误一个方向。反静默的原始意图仍保留——被放弃的只是"编一个数字"这个手段。
|
||||||
|
- **独立审查(两轮)抓出的两处实质缺陷**: ① 只改失败侧结算不够,`retry.py:338`/`embedding.py:271` 的**成功侧**取自同一返回值,改前 `actual` 恰等于预扣量使 `delta==0`,不同改则成功调用押金被整笔退回,对"从不回 usage 帧的网关源"构成系统性 TPM 失效;② OCR 行原判为"假陈述"是错的——`types.py:51` 与 `ocr.py:9` 明示 OCR 的 0 token 属**事实**,且改标 `unavailable` 会灌水本方案赖以成立的缺口度量,已剔出。
|
||||||
|
- **待办**: 经 `writing-plans` 出实施计划;发版须同步 CHANGELOG 与 wiki(缺口查询口径须带 `AND cache_hit = false`),并回帖 issue #2。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:settings-invariants-round-2
|
||||||
|
title: "GatewaySettings 装配校验补齐(第二轮)"
|
||||||
|
date: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# GatewaySettings 装配校验补齐(第二轮)
|
||||||
|
|
||||||
|
全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。
|
||||||
|
|
||||||
|
- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。
|
||||||
|
- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。
|
||||||
|
- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。
|
||||||
|
- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。
|
||||||
|
- **本轮唯一新决策**: `_load_pg_dsn` 剥 `+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。
|
||||||
|
- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。
|
||||||
@@ -95,6 +95,21 @@
|
|||||||
"id": "design:settings-invariant-guards",
|
"id": "design:settings-invariant-guards",
|
||||||
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
"label": "GatewaySettings 跨字段不变量守卫的生效范围",
|
||||||
"type": "design"
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:settings-invariants-round-2",
|
||||||
|
"label": "GatewaySettings 装配校验补齐(第二轮)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:est-tokens-decoupling",
|
||||||
|
"label": "est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:est-tokens-decoupling",
|
||||||
|
"label": "est_tokens 解耦实施计划",
|
||||||
|
"type": "plan"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -160,6 +175,20 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "T0-T14 逐节实现设计 §4-§10/§15",
|
"evidence": "T0-T14 逐节实现设计 §4-§10/§15",
|
||||||
"added": "2026-07-22T09:33:05.357964+00:00"
|
"added": "2026-07-22T09:33:05.357964+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "design:est-tokens-decoupling",
|
||||||
|
"target": "design:m1-core-design",
|
||||||
|
"relation": "refines",
|
||||||
|
"evidence": "精化 M1 冻结的 est_tokens 双职责语义: 保留 TPM 预扣、推翻 usage 缺失按 est 兜底(m1-core-design.md:59,222),改记 0/0 + unavailable + cost NULL",
|
||||||
|
"added": "2026-07-30T09:33:55.383401+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:est-tokens-decoupling",
|
||||||
|
"target": "design:est-tokens-decoupling",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
||||||
|
"added": "2026-07-30T09:39:26.442986+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-07-30 03:44 UTC
|
> 自动生成,更新时间:2026-07-30 09:39 UTC
|
||||||
|
|
||||||
## design (12)
|
## design (16)
|
||||||
- [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`
|
||||||
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
- [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design`
|
||||||
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design`
|
- [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-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-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-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
|
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||||
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||||
@@ -29,12 +33,14 @@
|
|||||||
- [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 (10)
|
## plan (12)
|
||||||
- [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`
|
||||||
|
- [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`
|
||||||
|
|||||||
@@ -48,3 +48,12 @@
|
|||||||
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
- [2026-07-22 14:36 UTC] 重建索引: 34 篇页面
|
||||||
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
- [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards)
|
||||||
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
- [2026-07-30 03:44 UTC] 重建索引: 36 篇页面
|
||||||
|
- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2)
|
||||||
|
- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面
|
||||||
|
- [2026-07-30 09:32 UTC] 新增 design: est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2) (design:est-tokens-decoupling)
|
||||||
|
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
|
||||||
|
- [2026-07-30 09:33 UTC] 新增边: design:est-tokens-decoupling --refines--> design:m1-core-design
|
||||||
|
- [2026-07-30 09:33 UTC] 重建索引: 40 篇页面
|
||||||
|
- [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] 重建索引: 42 篇页面
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
| 项目键(.env.example 实测) | 库对应 | 差异 |
|
| 项目键(.env.example 实测) | 库对应 | 差异 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 见 ⚠️ G2 |
|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 同名继承 | 无;`EST_TOKENS` 键保留不改名,但 2026-07-30 起由必填降为可选(G2 已闭,详见该行) |
|
||||||
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) |
|
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY/RPM/TPM`(config.py:249-271) | 全局闸限额 | ARCH §9 未定义 GLOBAL 段命名,M2 设计须定(建议原样继承) |
|
||||||
| `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 |
|
| `{SCOPE}__SELECTOR`(round_robin/least_inflight) | `SourceSelector` 策略选择 | 命名待 M1/M2 定稿,建议继承 |
|
||||||
| `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 |
|
| `{SCOPE}__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` | RetryPolicy | ⚠️ G4:ARCH §9 只列平铺 `LLM_MAX_RETRIES` 等键,无 per-scope 形态 |
|
||||||
@@ -148,7 +148,7 @@ stack = ExtractionProviderStack(
|
|||||||
| Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) |
|
| Retry-After 仅支持秒数形态(invokers.py:127-141) | HTTP-date 返回 None | **有意放弃** date 形态(ARCH §6.2 同款) |
|
||||||
| 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) |
|
| 429 body 细分 insufficient_quota → SourceDead(invokers.py:144-166) | 欠费≠限流 | **保留**(ARCH §6.1 已承诺) |
|
||||||
| 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** |
|
| 零 content 提前结束 → Transient "early_eof";有 content 缺 [DONE] → 打捞并埋点 "missing_done"(invokers.py:306-313) | 线路级异常定性(D2 的核心价值) | **保留** |
|
||||||
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **保留**(`usage_source` 已进 ARCH §5.1;依赖 G2) |
|
| usage 缺失按 est_tokens 估算并标 `estimated`,不静默用 0(invokers.py:241-254) | 保守计量 | **有意放弃**(2026-07-30 推翻原"保留"判定,est_tokens 解耦设计 §4)。理由:CHS 只记单个 `total_tokens`,不存在 prompt/completion 分配问题;库拆成两列后无法忠实分配,而 `est_tokens` 按 CHS 自身定义(config.py:55)是**最坏情形上界**——"保守"在限流语境安全(押多了只是慢),在计费语境只有虚高一个方向。库改为如实记 `0/0` + `usage_source="unavailable"` + cost NULL(ARCH §5.1)。**"不静默用 0"的原始意图完整保留**:被放弃的只是"编一个数字"这个手段,缺失依然有显式标注且可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
|
||||||
| reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) |
|
| reasoning_content 刷新活性但不计入结果;ttft=首个任意 token(invokers.py:55-79, 336-364) | 防 thinking 模型被看门狗误杀 | **替换+增强**:库把 thinking 收进 `LLMResponse.thinking`(不再丢弃);活性语义必须保留(反向约束 M1) |
|
||||||
| enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) |
|
| enable_thinking=True 不注入参数、False 注入关闭参数(invokers.py:230-238) | 与 D11 注册表"声明注入方式"方向相反 | **替换**(provider 注册表须支持"注入关闭参数"形态) |
|
||||||
| 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) |
|
| 图片 magic bytes 探测,非 PNG/JPEG 抛 RequestRejected(invokers.py:116-123) | 本地快速拒绝 | **保留**(移入库 transport) |
|
||||||
@@ -182,7 +182,7 @@ stack = ExtractionProviderStack(
|
|||||||
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
|
| R5 | RequestRejected 二分(真实响应记成功/本地拒绝释放探针);换源重试跨源计数口径 | M2 | 须进 M2 设计 |
|
||||||
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
|
| R6 | OCR ZIP 协议 + bbox 数值防御下沉;OCR Usage=0;glm 白名单预留 | M3 | §7.10 已覆盖 |
|
||||||
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
|
| **G1** | ✅ 已闭(M3 核实): 库 `GatewayUnavailableError` 一族自 M1 起携 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),chat/embedding/OCR 三循环抛出点均已填充且有契约测试钉住;项目侧仅剩约 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常 | M2 | 已闭 |
|
||||||
| **G2** | ⚠️ `est_tokens`(TPM 预扣常量 + usage 缺失兜底,config.py:55)不在 ARCH §7.7 SourceConfig 字段清单;§7.3 `try_acquire(source, est_tokens)` 的 est 来源未定义 | M2 | **架构缺口**,修订 §7.7 |
|
| **G2** | ✅ 已闭(2026-07-30 核实): `est_tokens` 已进 ARCH §7.7 SourceConfig 字段清单,且 §7.3 `try_acquire` 的 est 来源已定义为 `SourceConfig.effective_est_tokens()`(显式值优先,否则按 `tpm // 60` 派生)。双职责一并拆开:该字段只剩 TPM 预扣的可选调优覆盖,usage 缺失不再由它兜底(见 §7 行 151 的推翻判定) | M2 | 已闭 |
|
||||||
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
|
| **G3** | ⚠️ §4.3 层序图文矛盾:图示 熔断→限流→重试(重试最内),但理由要求"每次重试重新过限流闸"且熔断/限流是 per-source 的、选源在重试循环内(governance.py:120-167 实践为每次尝试执行 选源→冷却备忘→permit→熔断门)。洋葱不澄清"逐次准入"机制则多源语义无法成立 | M2 | **架构缺口**,澄清 §4.3/§4.4 |
|
||||||
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
|
| **G4** | ⚠️ per-scope 韧性配置命名(`{SCOPE}__RETRY__*`/`BREAKER__*`/`BACKPRESSURE__*`/`SELECTOR`/`GLOBAL__*`)未进 ARCH §9,现文只有平铺 `LLM_*` 键;CHSAnalyzer 的 VLM/OCR 两 scope 参数各异,平铺键无法表达 | M2 | **架构缺口**,修订 §9 |
|
||||||
| **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 |
|
| **G5** | ⚠️ 半开探针租约 TTL(探针持有者死亡后 TTL 过期自动可再探,scripts.py:96-105)与 `release_probe` 操作未见于 ARCH §7.4(只写单探针/epoch fencing);缺失则探针死锁 | M2 | **架构缺口**,修订 §7.4 |
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ loop = AgentLoop(client, max_steps=...,
|
|||||||
| B10 | 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) | **保留**(库同款语义) |
|
| B10 | 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) | **保留**(库同款语义) |
|
||||||
| B11 | SSE 流提前断开且未见 `[DONE]` 时正常返回:`usage_sink["done"]` 写入后无人检查(client.py:115-117),截断响应被当成功**并写入缓存** | **修复**: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存 |
|
| B11 | SSE 流提前断开且未见 `[DONE]` 时正常返回:`usage_sink["done"]` 写入后无人检查(client.py:115-117),截断响应被当成功**并写入缓存** | **修复**: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存 |
|
||||||
| B12 | provider 差异靠字符串猜: `"deepseek" in provider`/`"qwen" in provider` 注入 thinking 参数(client.py:139-144)、`<think>` 剥离(client.py:348) | **替换**: provider 注册表(D11) |
|
| B12 | provider 差异靠字符串猜: `"deepseek" in provider`/`"qwen" in provider` 注入 thinking 参数(client.py:139-144)、`<think>` 剥离(client.py:348) | **替换**: provider 注册表(D11) |
|
||||||
| B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source=measured/estimated` |
|
| B13 | usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 | **升级**: 库 `usage_source` 三态 `measured/estimated/unavailable`(2026-07-30 est_tokens 解耦后由两态扩为三态)。GovDoc 原行为"落 0 且无标注"中的落 0 反而与库一致,被升级的是**标注**:缺失行记 `unavailable` 且 cost 为 NULL,缺口可被 `WHERE usage_source='unavailable' AND cache_hit = false` 量化 |
|
||||||
| B14 | 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) | **升级**: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移 |
|
| B14 | 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) | **升级**: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移 |
|
||||||
| B15 | 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) | **有意保留**(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效 |
|
| B15 | 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) | **有意保留**(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效 |
|
||||||
| B16 | `CancelledError` 穿透重试循环(client.py:396 `except Exception` 天然放行),取消的调用**不记遥测** | **保留**穿透;取消是否记遥测库未定义,见 §9-R6 |
|
| B16 | `CancelledError` 穿透重试循环(client.py:396 `except Exception` 天然放行),取消的调用**不记遥测** | **保留**穿透;取消是否记遥测库未定义,见 §9-R6 |
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# est_tokens 解耦实施计划
|
||||||
|
|
||||||
|
- **目标**: 把 `SourceConfig.est_tokens` 的两个职责(TPM 入场预扣 / usage 缺失时的遥测用量兜底)拆开,并解绑 `tpm > 0 ⇒ est_tokens > 0` 装配约束。
|
||||||
|
- **方案概述**: usage 不可得时遥测记 `0/0` 并标新值 `unavailable`、cost 落 NULL(不再拿限流押金编计费数字);`est_tokens` 未填时由库按 `tpm // 60` 派生预扣量,字段降为可选调优覆盖(保留不删不改名)。全部依据已批准设计 `research-wiki/designs/2026-07-30-est-tokens-decoupling-design.md`,其 §3.2 的 11 条改动项已钉死行号。
|
||||||
|
- **涉及技术**: Python 3.11 frozen dataclass、pytest(含 `tests/contracts` 双后端契约测试)、真实 Redis(integration/contracts)、pydantic-settings 不涉及改动。
|
||||||
|
- **溯源**: Gitea issue #2;wiki 实体 `design:est-tokens-decoupling`。
|
||||||
|
|
||||||
|
## 1. 任务排序的硬约束(先读这节再动手)
|
||||||
|
|
||||||
|
三处改动互相牵制,**顺序错了会引入押金泄漏或计费造假**,且中间态不报错、只静默偏差:
|
||||||
|
|
||||||
|
| 若单独先做 | 后果 |
|
||||||
|
|---|---|
|
||||||
|
| 先改 usage 兜底为 `(0, 0)`,结算点还没切派生值 | 成功调用 `actual = 0` 而入场押了 `est_tokens`,`delta` 为负 → **押金整笔退回**,TPM 闸退化成进门即放行 |
|
||||||
|
| 先切入场(`ratelimit.py:26`)+ 解绑约束,而结算点还没切 | `est_tokens=0` 的源入场押派生值、结算退 0 → 同样泄漏。**注意机制**:若**只**解绑约束而 `ratelimit.py` 一行未动,后果不是泄漏而是入场**完全不预扣**(仍传 `est_tokens=0`)——那是设计 §2.2 已否决的"不预扣"退化形态。两者都要避免,故 T4 必须在 T2 之后 |
|
||||||
|
| 先改 `openai_compat.py:176` 而 `_merge` 还是二值逻辑 | embedding 的 `unavailable` 批被 `any(== "estimated")` 判 False 从而**误标 `measured`**,cost 照算 |
|
||||||
|
|
||||||
|
因此排序为:**先加能力(零行为变更)→ 再把所有调用点切到新能力(此时等价,因显式值优先)→ 再让三态生效 → 最后解绑约束**。Task 1-2 完成后行为逐字不变,Task 3 才是行为变更主体,Task 4 才让派生值真正启用。**不要合并或调换 Task 2 / 3 / 4 的顺序。**
|
||||||
|
|
||||||
|
## 2. 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 | 涉及任务 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 新增 `SourceConfig.effective_est_tokens()` 派生方法与 `usage_source` 三态值域常量;删除 `_validate_gates` 里的条件必填;修 `EmbeddingTransportResult` 行内注释 | T1、T3、T4 |
|
||||||
|
| `src/polygateway/middleware/ratelimit.py` | `QuotaGate.try_acquire` 入场预扣改用派生值 | T2 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 成功侧(`:338`)与失败侧(`:370`)结算改用派生值 | T2 |
|
||||||
|
| `src/polygateway/embedding.py` | 同上(`:271`/`:294`);`_merge` 三态合并;`_total_cost` 存在不可得批时整体 NULL | T2、T3 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 两处 usage 兜底改 `unavailable`;打捞覆盖加前置条件 | T3 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | cost 短路(插在 `cache_hit` 之后);失败尝试与终态失败改标 `unavailable` | T3 |
|
||||||
|
| `src/polygateway/ocr.py` | **不改**(设计 §3.3 已剔出),仅加防回归测试 | T3 |
|
||||||
|
| `research-wiki/ARCHITECTURE.md` | §7.7 行 428、§5.1 行 331、§4.4 行 305、§7.1 行 384 | T5 |
|
||||||
|
| `research-wiki/migrations/chsanalyzer.md` | 行 151 保留项改判为有意放弃;G2(行 185)标注已解决 | T5 |
|
||||||
|
| `.env.example` | 行 11 删除"TPM > 0 时 EST_TOKENS 必填 > 0" | T5 |
|
||||||
|
| `CHANGELOG.md` | 行为变更小节(值域新增 + cost 口径 + 约束解绑) | T5 |
|
||||||
|
|
||||||
|
测试文件:`tests/unit/test_types.py`、`test_retry.py`、`test_openai_compat.py`、`test_telemetry.py`、`test_embedding.py`、`test_ocr_client.py`、`tests/contracts/test_limiter_contract.py`。
|
||||||
|
|
||||||
|
## 3. 保真校验
|
||||||
|
|
||||||
|
本计划**不新增**任何 `reference/` 移植代码,但触及 ARCHITECTURE §1.4 关键资产索引中的"遥测口径"与"限流结算",且**有意推翻**一条已声明保留的迁移行为(CHS `invokers.py:241-254` 的"usage 缺失按 est 估算",见设计 §4)。故设以下检查点,每个任务完成时逐条确认:
|
||||||
|
|
||||||
|
1. `Permit.settle()` 的"多退少补 + 幂等 flag"语义不得改变;`release()` 的 finally 必然执行不得改变。
|
||||||
|
2. **不得触碰** `backends/redis/limiter.py` 的 Lua 脚本与 `backends/memory/limiter.py` 的窗口/租约算法——本计划只改传入 `try_acquire` 的**数值来源**,不改闸门算法。
|
||||||
|
3. 不得改变 `errors.py` 四分类归属,不得新增运行时异常类型;值域违反不走异常路径(设计 §3.1 已裁决)。
|
||||||
|
4. `ocr.py` 的 `settle` 恒 0 与 `usage_source="measured"` 保持不变。
|
||||||
|
5. 遥测 18 字段冻结不变、无 DDL 变更(两 schema 的 `cost` 列已可空)。
|
||||||
|
|
||||||
|
## 4. 任务清单
|
||||||
|
|
||||||
|
### T1 — 加派生能力与值域常量(零行为变更)
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/types.py`
|
||||||
|
|
||||||
|
新增模块级值域常量与 `SourceConfig` 方法。派生按**源自身 tpm**,全局 tpm 不参与(设计 §7 已声明为既有限制、本次不修):
|
||||||
|
|
||||||
|
```python
|
||||||
|
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||||
|
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
|
||||||
|
|
||||||
|
_EST_TOKENS_QUOTA_DIVISOR = 60
|
||||||
|
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def effective_est_tokens(self) -> int:
|
||||||
|
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
|
||||||
|
if self.est_tokens > 0:
|
||||||
|
return self.est_tokens
|
||||||
|
if self.tpm > 0:
|
||||||
|
return max(1, self.tpm // _EST_TOKENS_QUOTA_DIVISOR)
|
||||||
|
return 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收标准**: 方法存在且为纯函数(不读全局、不 await);此任务**不修改任何调用点**,全库行为逐字不变。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过:方法不存在时 `AttributeError`)——`tests/unit/test_types.py`:
|
||||||
|
- `tpm=6000, est_tokens=0` → `100`;`tpm=600000, est_tokens=0` → `10000`(尺度无关:两者在途上限同为 60)
|
||||||
|
- `tpm=30, est_tokens=0` → `1`(下界不塌到 0)
|
||||||
|
- `tpm=0, est_tokens=0` → `0`(TPM 闸未启用,不预扣)
|
||||||
|
- `tpm=6000, est_tokens=4000` → `4000`(显式值优先于派生)
|
||||||
|
- `USAGE_SOURCES` 恰为三元集合
|
||||||
|
|
||||||
|
**值域封闭的两条实质断言**(设计 §6 要求;缺了它们 `USAGE_SOURCES` 会沦为零消费者的死常量,且 §3.1 的落点裁决无回归保护):
|
||||||
|
- **生产侧封闭**: 参数化覆盖库内全部 `usage_source` 生产点(`_resolve_usage`、`_resolve_embedding_usage`、`_merge`、`TelemetryEmitter.emit_*`),断言产出恒 ∈ `USAGE_SOURCES`。此断言在 T1 阶段即可写(此时产出仅 `measured`/`estimated`),T3 完成后自动覆盖 `unavailable`
|
||||||
|
- **不做运行时校验**: `LLMResponse(usage_source="garbage")` 构造**不抛异常**——锁定设计 §3.1 的裁决(公共 frozen dataclass 不加 `__post_init__` 值域校验,否则裸 `ValueError` 不属四分类、会逃出 `chat()`)。没有这条,后人很容易顺手补上校验而击穿 `chat()`
|
||||||
|
|
||||||
|
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
|
||||||
|
|
||||||
|
### T2 — 五个调用点切到派生值(零行为变更)
|
||||||
|
|
||||||
|
此时 `est_tokens > 0` 仍是必填(约束未解绑),故 `effective_est_tokens()` 恒返回显式值,**行为与改前逐字相同**。这一步只是把数值来源换掉,为 T3 铺路。
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/middleware/ratelimit.py:26` — `source.est_tokens` → `source.effective_est_tokens()`
|
||||||
|
- [x] **改** `src/polygateway/middleware/retry.py:370`(失败侧,`if not dead` 分支内)→ `source.effective_est_tokens()`
|
||||||
|
- [x] **改** `src/polygateway/embedding.py:294`(失败侧)→ 同上
|
||||||
|
- [x] **改** `src/polygateway/middleware/retry.py:338`(成功侧)— 加不可得分支:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if result.usage_source == "unavailable":
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
|
else:
|
||||||
|
actual = result.prompt_tokens + result.completion_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/embedding.py:271`(成功侧)— 同构,`actual = result.prompt_tokens` 落在 else 分支。
|
||||||
|
|
||||||
|
`TransportResult.usage_source`(`types.py:75`)与 `EmbeddingTransportResult.usage_source`(`types.py:273`)均为必填字段,在这两处的 `result` 局部变量上直接可读。
|
||||||
|
|
||||||
|
**验收标准**: 五处均不再直接读 `source.est_tokens`;新分支在本任务中**永不触发**(尚无 `unavailable` 生产者),现有测试全绿即证明零行为变更。**不要**在本任务修改 `retry.py:329` 的 `actual = 0` 初值,也不要动 `RequestRejectedError`/`ResultInvalidError`/`SourceDeadError` 三条失败分支(设计 §3.3:它们的 `actual` 停在 0 属既有行为)。
|
||||||
|
|
||||||
|
**测试要求**(回归保护,先失败后通过不适用于零行为变更任务,故以"现有测试不得回归 + 新增等价性断言"为门):
|
||||||
|
- 新增 `tests/unit/test_retry.py`:`tpm=1000, est_tokens=400` 的源在 usage 正常返回时,settle 收到的 `actual` 等于实测 token 之和(锁定 else 分支);现有 `test_settle_uses_actual_usage` 必须继续通过
|
||||||
|
- `tests/contracts/test_limiter_contract.py` 全绿(双后端)
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
```
|
||||||
|
conda run --no-capture-output -n PolyGateway pytest tests/unit tests/contracts -v
|
||||||
|
```
|
||||||
|
→ 全 PASS。**Redis 契约测试用真实 Redis db3,不得与其他 Redis 测试并跑**(时序隔离)。
|
||||||
|
|
||||||
|
### T3 — 值域三态生效(行为变更主体)
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/transports/openai_compat.py:146` — 兜底不再读 `est_tokens`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return 0, 0, "unavailable"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/transports/openai_compat.py:176` — embedding 兜底同理 `return 0, "unavailable"`
|
||||||
|
- [x] **改** `src/polygateway/transports/openai_compat.py:336` — 打捞覆盖加前置条件(否则 `0/0` 会被标 `estimated` 而算出假的 `0.0`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if salvaged and usage_source == "measured":
|
||||||
|
usage_source = "estimated" # 收到 usage 帧但流被截断: 数字真实、可信度降级
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/middleware/telemetry.py:130-135` — cost 短路,**插在 `cache_hit` 分支之后**(缓存命中未产生新调用,`0.0` 是事实):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if cache_hit:
|
||||||
|
cost: float | None = 0.0
|
||||||
|
elif usage_source == "unavailable":
|
||||||
|
cost = None # 用量不可得: 宁可算不出成本,也不算错成本
|
||||||
|
elif error is None and model and self._pricing is not None:
|
||||||
|
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
||||||
|
else:
|
||||||
|
cost = None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/middleware/telemetry.py:58` 与 `:100` — 失败尝试与终态失败的 `usage_source` 由 `"estimated"` 改 `"unavailable"`(cost 本已是 None,不改金额)
|
||||||
|
- [x] **改** `src/polygateway/embedding.py:383,390` — 二值合并扩三态(优先级:任一不可得 → 整体不可得):
|
||||||
|
|
||||||
|
```python
|
||||||
|
sources = {o.result.usage_source for o in outcomes}
|
||||||
|
if "unavailable" in sources:
|
||||||
|
merged_source = "unavailable"
|
||||||
|
elif "estimated" in sources:
|
||||||
|
merged_source = "estimated"
|
||||||
|
else:
|
||||||
|
merged_source = "measured"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/embedding.py:397` `_total_cost` — 存在 `unavailable` 批时整体返回 `None`(逐批求和会给出偏低却看似有效的金额)
|
||||||
|
- [x] **改** `src/polygateway/types.py:273` — 行内注释 `# measured | estimated` → 三态(内核里不留矛盾注释)
|
||||||
|
- [x] **改** `src/polygateway/transports/openai_compat.py:142` 与 `:172` — 两个函数的中文 docstring 仍写着"缺失/非法按 `est_tokens` 保守兜底并标 `estimated`",改完不改就留下两句主动陈述旧行为的文档(与 `types.py:273` 同一把尺子)
|
||||||
|
|
||||||
|
**验收标准**: 全库不再有任何位置把 `est_tokens` 写进遥测用量;`ocr.py` 一字未动。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过):
|
||||||
|
- `test_openai_compat.py`:`est_tokens=4000` + usage 帧缺失 → `(0, 0, "unavailable")`(**改前返回 `(0, 4000, "estimated")`,故先失败**;现有用例 `test_usage_missing_falls_back_to_est` 需改写)
|
||||||
|
- **改写** `tests/unit/test_embedding.py:105 test_missing_usage_falls_back_estimated` — 现断言 `prompt_tokens == 7 and usage_source == "estimated"`(夹具 `est_tokens=7`),改 `openai_compat.py:176` 后必然变红,须改为 `(0, "unavailable")`。这是一条**位于 `test_embedding.py` 里的 transport 级用例**,容易在只盯 `test_openai_compat.py` 时漏掉
|
||||||
|
- `test_openai_compat.py`:打捞 + usage 帧存在 → `estimated` **且 cost 非 None**;打捞 + usage 缺失 → `unavailable` **且 cost 为 None**。cost 配套断言不可省——#4 的真正目的就是防 `0/0` 被算成假的 `0.0`,只断言 `usage_source` 钉不住它
|
||||||
|
- `test_telemetry.py`:成功行 `usage_source="unavailable"` → `record_llm_call` 收到 `cost=None`(改前按 4000×输出单价算出 `0.032`)
|
||||||
|
- `test_telemetry.py`:`cache_hit=True` 且 `unavailable` → cost 仍为 `0.0`(锁定分支次序)
|
||||||
|
- `test_telemetry.py`:失败尝试与终态失败行 `usage_source == "unavailable"`
|
||||||
|
- `test_embedding.py`:混合批 `measured + unavailable` → 整体 `unavailable` 且 `cost is None`(改前误标 `measured`)
|
||||||
|
- `test_ocr_client.py`:OCR 成功行仍为 `measured` 且 settle 恒 0(**防回归**,锁定设计 §3.3 的剔出决定)
|
||||||
|
|
||||||
|
**验证**: `conda run --no-capture-output -n PolyGateway pytest tests/unit -v` → 全 PASS;`conda run --no-capture-output -n PolyGateway pytest tests/contracts -v` → 全 PASS(T2 的结算分支此时首次被激活,契约测试须复跑)
|
||||||
|
|
||||||
|
### T4 — 解绑装配约束(派生值真正启用)
|
||||||
|
|
||||||
|
- [x] **改** `src/polygateway/types.py:125-126` — 删除:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.tpm > 0 and self.est_tokens <= 0:
|
||||||
|
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)")
|
||||||
|
```
|
||||||
|
|
||||||
|
`_validate_gates` 的其余部分(`timeout_s > 0`、四个限额非负)**保留不动**。`est_tokens` 字段本身与 `{SCOPE}__{PROVIDER}__{N}__EST_TOKENS` 环境键保留不删不改名(迁移兼容硬约束);`config.py:40` 的键映射无需改动。
|
||||||
|
|
||||||
|
**验收标准**: `tpm=6000, est_tokens=0` 可构造;该源入场预扣 100,**成功侧与非 dead 瞬时失败侧**按 100 结算(delta=0)。**取消 / RequestRejected / ResultInvalid / SourceDead 四侧维持既有的 `actual=0` 全额退回**——`retry.py:355-359` 的取消分支不给 `actual` 赋值、停在 `:329` 初值,这是设计 §3.3 声明不动的既有行为,**不要**为了凑"三侧一致"去改它。
|
||||||
|
|
||||||
|
**测试要求**(先失败后通过:改前构造即抛 `ValueError`):
|
||||||
|
- **改写** `tests/unit/test_types.py:94 test_tpm_requires_est_tokens` — 它现在断言 `_make_source(tpm=10000, est_tokens=0)` 抛 `ValueError`,删约束后必然变红。保留后半条正向断言(`est_tokens=800` 仍原样返回),把前半条改为"构造成功且 `effective_est_tokens()` 返回派生值"
|
||||||
|
- `test_retry.py`:**成功侧**——未填 `est_tokens`、`tpm>0`、usage 帧缺失的成功调用后,TPM 窗口残留量等于派生预扣量而非 0(**这是设计中最易漏的一条**,回归 §3.2 #9;在 `test_retry.py:149` 的 `_src("a", tpm=1000, est_tokens=400)` 旁加 `est_tokens=0` 用例)
|
||||||
|
- `test_retry.py`:**失败侧**——同配置的非 dead 瞬时失败调用后,窗口残留量同为派生预扣量(回归 §3.2 #8)
|
||||||
|
- `tests/contracts/test_limiter_contract.py`:**只加后端级断言**——传入派生值时双后端的结算口径一致。**不要**在契约文件里写端到端用例:该文件直接驱动 limiter(形如 `limiter.try_acquire("s1", 0)`),不经 `QuotaGate`/`RetryMW`,照字面写会产出 `try_acquire(src.effective_est_tokens())` + `settle(同值)` 的退化用例——只测了后端算术,没测调用点是否真的切了派生值。上面两条端到端断言的载体是 `retry.py`,放 `test_retry.py`(内存后端)
|
||||||
|
|
||||||
|
**验证**: `make ci`(即 check + test,含 import-linter 契约)→ 全 PASS。**不要**在外层再套 `conda run`:`Makefile` 的 `check`/`test` 目标内部已各自 `conda run -n $(ENV)`,嵌套后外层的 `--no-capture-output` 也管不到内层缓冲
|
||||||
|
|
||||||
|
### T5 — 权威文档与发布物同步
|
||||||
|
|
||||||
|
- [x] **改** `research-wiki/ARCHITECTURE.md` 四处:§7.7 行 428(`est_tokens` 描述:可选调优覆盖 + 派生规则,删去"亦作 usage 缺失时的保守兜底")、§5.1 行 331(`usage_source` 三态 + cost NULL 口径)、§4.4 行 305("token 按 `est_tokens` 预扣" → 按有效预扣量)、§7.1 行 384(打捞路径强制 `estimated` → 仅在收到 usage 帧时降级)
|
||||||
|
- [x] **改** `research-wiki/migrations/chsanalyzer.md`:行 151 由"保留"改判"**有意放弃**"并写入设计 §4 的理由(CHS 只记单个 `total_tokens` 不存在分配问题;保守在计费语境无安全方向);G2(行 185)标注已由本设计解决
|
||||||
|
- [x] **改** `.env.example` 行 11:删除"TPM > 0 时 EST_TOKENS 必填 > 0",改注为"可选;未填则库按 tpm 派生"
|
||||||
|
- [x] **改** `CHANGELOG.md`:新增"行为收紧/变更"小节三条——`usage_source` 新增 `unavailable`、用量不可得行 cost 由数值变 NULL、`est_tokens` 降为可选
|
||||||
|
- [x] **改** wiki 用户文档站(按 `docs-convention.md` §2):usage/成本口径说明须写明缺口查询为 `WHERE usage_source='unavailable' AND cache_hit = false`(**必须带 `cache_hit` 限定**:缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高)
|
||||||
|
- [x] **回帖** Gitea issue #2:结论与下游可删绕行校验的时点
|
||||||
|
|
||||||
|
**验收标准**: 全库 grep `EST_TOKENS 必填`、`est_tokens` 兜底相关表述无残留;ARCHITECTURE.md 无自相矛盾表述。
|
||||||
|
|
||||||
|
**测试要求**: 纯文档,无行为测试。以 `grep` 输出为验收证据。
|
||||||
|
|
||||||
|
**验证**: `make ci` → PASS;`grep -rn "EST_TOKENS 必填" . --exclude-dir=.git` → 无输出
|
||||||
|
|
||||||
|
## 5. 完成判定
|
||||||
|
|
||||||
|
- [x] T1-T5 全部 checkbox 勾选,每个任务一次语义化提交(`commit` skill)
|
||||||
|
- [x] `make ci` 全绿(含 ruff、import-linter 洋葱契约、pytest 覆盖率)
|
||||||
|
- [x] 设计 §6 测试表的 10 行断言全部有对应测试且可出示"先失败后通过"证据(T2 的零行为变更任务以"现有测试不回归 + 等价性断言"替代)
|
||||||
|
- [x] 派新上下文 verifier subagent 独立验证(`verification-before-completion`,里程碑级/跨多文件硬门)
|
||||||
|
- [x] 版本 bump 与 CHANGELOG 同步发布(不得裸发)
|
||||||
|
|
||||||
|
## 6. 明确不做
|
||||||
|
|
||||||
|
派生值取全局与单源 tpm 较紧者(需改三处 `QuotaGate` 装配,修的是既有缺口,设计 §7 已声明另开 issue);遥测驱动的 p90 自适应预估(设计 §5 已否决,待实测证据);`ocr.py` 的 usage 标记(设计 §3.3 已剔出);`retry.py` 另外三条失败分支的 `actual` 初值。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:est-tokens-decoupling
|
||||||
|
title: "est_tokens 解耦实施计划"
|
||||||
|
date: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# est_tokens 解耦实施计划
|
||||||
|
|
||||||
|
全文见 [2026-07-30-est-tokens-decoupling-plan.md](2026-07-30-est-tokens-decoupling-plan.md)。实现设计 [est-tokens-decoupling](../designs/est-tokens-decoupling.md)。
|
||||||
|
|
||||||
|
- **5 个任务**: T1 加派生能力与三态值域常量(零行为变更)→ T2 五个入场/结算点切到派生值(零行为变更,因显式值优先)→ T3 值域三态生效(行为变更主体)→ T4 解绑 `tpm > 0 ⇒ est_tokens > 0`(派生值真正启用)→ T5 权威文档、CHANGELOG、wiki 与 issue 回帖。
|
||||||
|
- **排序是硬约束,不可调换**: 三处改动互相牵制且中间态**静默偏差、不报错**。先改 usage 兜底为 `(0,0)` 而结算点未切派生值 → 成功调用押金整笔退回(TPM 闸退化成进门即放行);先解绑约束而结算点未切 → 同样泄漏;先改 `openai_compat.py:176` 而 `_merge` 仍是二值 `any(=="estimated")` → `unavailable` 批被误标 `measured` 且 cost 照算。
|
||||||
|
- **T2 的等价性是安全阀**: 约束未解绑时 `effective_est_tokens()` 恒返回显式值,故 T1/T2 后行为逐字不变,现有测试全绿即为证明;T3 才是唯一的行为变更点。
|
||||||
|
- **保真校验(不新增移植,但触及关键资产)**: 不得改 Redis Lua 与内存后端的窗口/租约算法(只改传入 `try_acquire` 的数值来源)、不得改 `settle` 多退少补与幂等语义、不得改错误四分类归属、`ocr.py` 一字不动、遥测 18 字段冻结且无 DDL。
|
||||||
|
- **最易漏的测试**: T4 的"成功侧结算不退多"——未填 `est_tokens` 且 usage 帧缺失的**成功**调用后,TPM 窗口残留须等于派生预扣量而非 0。这正是独立审查在设计阶段抓出的缺陷,实施阶段必须有回归钉死。
|
||||||
|
- **发布口径**: 缺口度量必须写成 `WHERE usage_source='unavailable' AND cache_hit = false`——缓存命中行按裁决 cost 为 `0.0` 且标 `unavailable`,本无账目缺口,不加限定则度量偏高。
|
||||||
@@ -16,15 +16,25 @@ date: 2026-07-20
|
|||||||
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
|
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
|
||||||
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
|
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
|
||||||
| messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) |
|
| messages / response / thinking | TEXT NOT NULL | messages 落库前多模态 part 摘要(与缓存 key 共用 digest_messages) |
|
||||||
| prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;缺失按 est 兜底 |
|
| prompt_tokens / completion_tokens | INTEGER NOT NULL | usage 帧;帧缺失记 0/0(不编造估值,由 usage_source 标注) |
|
||||||
| usage_source | TEXT NOT NULL | measured / estimated |
|
| usage_source | TEXT NOT NULL | measured / estimated / unavailable(2026-07-30 起三态,见下) |
|
||||||
| latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 |
|
| latency_ms | INTEGER NOT NULL | 尝试耗时;缓存命中 0 |
|
||||||
| ttft_ms / max_inter_token_ms | REAL | 流式活性测量 |
|
| ttft_ms / max_inter_token_ms | REAL | 流式活性测量 |
|
||||||
| cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 |
|
| cache_hit | INTEGER NOT NULL DEFAULT 0 | 命中标记 |
|
||||||
| error | TEXT | 异常信息;取消记 "cancelled" |
|
| error | TEXT | 异常信息;取消记 "cancelled" |
|
||||||
| cost | REAL | M1 恒 NULL,M2 pricing 换算 |
|
| 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')) | 落库时刻 |
|
||||||
|
|
||||||
|
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||||
|
|
||||||
|
| usage_source | 含义 | 生产者 | cost |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
|
||||||
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
|
||||||
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
|
||||||
|
|
||||||
|
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
||||||
|
|
||||||
## 埋点位置(单一 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.1"
|
__version__ = "1.0.3"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ def _build_limiter(settings: GatewaySettings, sources: list[SourceConfig]) -> Ra
|
|||||||
if settings.limiter_backend == "redis":
|
if settings.limiter_backend == "redis":
|
||||||
from polygateway.backends.redis.limiter import RedisLimiter
|
from polygateway.backends.redis.limiter import RedisLimiter
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisLimiter.from_url(
|
return RedisLimiter.from_url(
|
||||||
settings.redis_url,
|
settings.redis_url,
|
||||||
scope=settings.scope,
|
scope=settings.scope,
|
||||||
@@ -279,7 +279,7 @@ def _build_breaker(settings: GatewaySettings) -> ProviderGate:
|
|||||||
if settings.breaker_backend == "redis":
|
if settings.breaker_backend == "redis":
|
||||||
from polygateway.backends.redis.breaker import RedisGate
|
from polygateway.backends.redis.breaker import RedisGate
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
return RedisGate.from_url(settings.redis_url, config=settings.breaker, scope=settings.scope)
|
||||||
return InMemoryGate(config=settings.breaker)
|
return InMemoryGate(config=settings.breaker)
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ def _build_cache(settings: GatewaySettings) -> CacheBackend | None:
|
|||||||
return InMemoryCache()
|
return InMemoryCache()
|
||||||
from polygateway.backends.redis_cache import RedisCache
|
from polygateway.backends.redis_cache import RedisCache
|
||||||
|
|
||||||
assert settings.redis_url is not None # 内部不变量: config 已校验
|
assert settings.redis_url is not None # 内部不变量: _validate_backends 已保证
|
||||||
return RedisCache.from_url(settings.redis_url)
|
return RedisCache.from_url(settings.redis_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -309,11 +309,11 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
|||||||
if settings.telemetry_backend == "postgres":
|
if settings.telemetry_backend == "postgres":
|
||||||
from polygateway.telemetry.postgres import PostgresRecorder
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: config 已校验
|
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: config 已校验
|
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+114
-14
@@ -16,6 +16,7 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
@@ -47,6 +48,12 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
||||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||||
|
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
|
||||||
|
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||||
|
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||||
|
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||||
|
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||||
|
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||||
_DEFAULT_POLL_INTERVAL_S = 0.05
|
_DEFAULT_POLL_INTERVAL_S = 0.05
|
||||||
@@ -122,15 +129,94 @@ class GatewaySettings:
|
|||||||
lease_ttl_s: float
|
lease_ttl_s: float
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self._validate_sources()
|
self._normalize()
|
||||||
|
self._validate_identity()
|
||||||
|
self._validate_backends()
|
||||||
|
self._validate_cache()
|
||||||
|
self._validate_telemetry()
|
||||||
self._validate_lease()
|
self._validate_lease()
|
||||||
self._validate_stall()
|
self._validate_stall()
|
||||||
self._validate_probe()
|
self._validate_probe()
|
||||||
|
|
||||||
def _validate_sources(self) -> None:
|
def _normalize(self) -> None:
|
||||||
"""零源的配置装出来选源必然失败,构造期即拒。"""
|
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||||
|
|
||||||
|
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。
|
||||||
|
一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑
|
||||||
|
scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错。
|
||||||
|
|
||||||
|
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
||||||
|
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
||||||
|
规范化留在 `_validate_telemetry`。
|
||||||
|
"""
|
||||||
|
normalized_scope = self.scope.strip().lower()
|
||||||
|
if normalized_scope != self.scope:
|
||||||
|
object.__setattr__(self, "scope", normalized_scope)
|
||||||
|
for field in ("redis_url", "pricing_path"):
|
||||||
|
if getattr(self, field) == "":
|
||||||
|
object.__setattr__(self, field, None)
|
||||||
|
|
||||||
|
def _validate_identity(self) -> None:
|
||||||
|
"""本类自身字段的基本域: 空 scope 会污染遥测与缓存命名空间;零源必然选源失败。"""
|
||||||
|
if not self.scope.strip():
|
||||||
|
raise ValueError("GatewaySettings.scope 不能为空")
|
||||||
if not self.sources:
|
if not self.sources:
|
||||||
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
|
raise ValueError("GatewaySettings.sources 不能为空: 至少一个源")
|
||||||
|
if self.structured_max_retries < 0:
|
||||||
|
raise ValueError(f"structured_max_retries 不能为负: {self.structured_max_retries}")
|
||||||
|
|
||||||
|
def _validate_backends(self) -> None:
|
||||||
|
"""后端选择必须落在合法域内,取 redis 的还必须有连接串。
|
||||||
|
|
||||||
|
域外取值此前只有 `from_env` 拦得住,直接构造会一路走到 `client.py` 的
|
||||||
|
`_build_*`,落进 else 分支静默不建后端,或撞上那里的断言。
|
||||||
|
"""
|
||||||
|
for field, allowed in (
|
||||||
|
("limiter_backend", _LIMITER_BACKENDS),
|
||||||
|
("breaker_backend", _BREAKER_BACKENDS),
|
||||||
|
("cache_backend", _CACHE_BACKENDS),
|
||||||
|
("telemetry_backend", _TELEMETRY_BACKENDS),
|
||||||
|
("selector", _SELECTORS),
|
||||||
|
("quota_full", _QUOTA_FULL),
|
||||||
|
):
|
||||||
|
value = getattr(self, field)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"{field} 非法值 {value!r};允许: {sorted(allowed)}")
|
||||||
|
on_redis = [f for f in _REDIS_DEPENDENT_BACKENDS if getattr(self, f) == "redis"]
|
||||||
|
if on_redis and self.redis_url is None:
|
||||||
|
raise ValueError(f"{'、'.join(on_redis)} 取 redis 时必须提供 redis_url")
|
||||||
|
|
||||||
|
def _validate_cache(self) -> None:
|
||||||
|
"""启用缓存必须有命名空间与正 TTL(缺命名空间即失去租户隔离,会毒化缓存)。"""
|
||||||
|
if self.cache_backend == "none":
|
||||||
|
return
|
||||||
|
if not self.cache_namespace:
|
||||||
|
raise ValueError("启用缓存时 cache_namespace 不能为空: 缓存 key 靠它做租户隔离")
|
||||||
|
if self.cache_ttl_s is None or self.cache_ttl_s <= 0:
|
||||||
|
raise ValueError(f"cache_ttl_s 必须 > 0(禁止永不过期): {self.cache_ttl_s}")
|
||||||
|
|
||||||
|
def _validate_telemetry(self) -> None:
|
||||||
|
"""遥测后端各自的落点必填;顺带剥掉 asyncpg 不认的 SQLAlchemy 驱动后缀。
|
||||||
|
|
||||||
|
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||||
|
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||||
|
他有权知道库动了他给的值。
|
||||||
|
"""
|
||||||
|
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||||
|
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||||
|
if self.telemetry_backend != "postgres":
|
||||||
|
return
|
||||||
|
if not self.telemetry_pg_dsn:
|
||||||
|
raise ValueError("telemetry_backend=postgres 时必须提供 telemetry_pg_dsn")
|
||||||
|
stripped = _strip_dsn_driver(self.telemetry_pg_dsn)
|
||||||
|
if stripped != self.telemetry_pg_dsn:
|
||||||
|
# 只报 scheme 段: DSN 带密码,整串不得进日志(P5 敏感信息只走 .env)
|
||||||
|
logger.warning(
|
||||||
|
"telemetry_pg_dsn 的 scheme 含 asyncpg 不认的驱动后缀,已由 {} 剥为 {}",
|
||||||
|
self.telemetry_pg_dsn.partition("://")[0],
|
||||||
|
stripped.partition("://")[0],
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "telemetry_pg_dsn", stripped)
|
||||||
|
|
||||||
def _validate_lease(self) -> None:
|
def _validate_lease(self) -> None:
|
||||||
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
"""调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。"""
|
||||||
@@ -318,17 +404,15 @@ def _load_choice(env: Mapping[str, str], key: str, allowed: frozenset[str], defa
|
|||||||
|
|
||||||
|
|
||||||
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||||
limiter_backend = _load_choice(
|
# 合法域与构造期守卫共用常量;此处的检查保留是为了报错能点出 env 键名,
|
||||||
env, "PGW_LIMITER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
# 构造期那道点的是字段名(两类调用方各看得懂自己那套)
|
||||||
)
|
limiter_backend = _load_choice(env, "PGW_LIMITER_BACKEND", _LIMITER_BACKENDS, "memory")
|
||||||
breaker_backend = _load_choice(
|
breaker_backend = _load_choice(env, "PGW_BREAKER_BACKEND", _BREAKER_BACKENDS, "memory")
|
||||||
env, "PGW_BREAKER_BACKEND", frozenset({"memory", "redis"}), "memory"
|
|
||||||
)
|
|
||||||
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
_, cache_backend = _require(env, "PGW_CACHE_BACKEND")
|
||||||
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
_, telemetry_backend = _require(env, "PGW_TELEMETRY_BACKEND")
|
||||||
if cache_backend not in ("redis", "memory", "none"):
|
if cache_backend not in _CACHE_BACKENDS:
|
||||||
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
raise ValueError(f"PGW_CACHE_BACKEND 非法值 {cache_backend!r}")
|
||||||
if telemetry_backend not in ("sqlite", "postgres", "none"):
|
if telemetry_backend not in _TELEMETRY_BACKENDS:
|
||||||
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
raise ValueError(f"PGW_TELEMETRY_BACKEND 非法值 {telemetry_backend!r}")
|
||||||
redis_url = env.get("REDIS_URL") or None
|
redis_url = env.get("REDIS_URL") or None
|
||||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||||
@@ -350,13 +434,22 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
def _strip_dsn_driver(dsn: str) -> str:
|
||||||
"""读取 Postgres DSN 并剥 SQLAlchemy 风格驱动后缀(asyncpg 不认 `+driver`)。"""
|
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||||
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
|
||||||
scheme, sep, rest = dsn.partition("://")
|
scheme, sep, rest = dsn.partition("://")
|
||||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pg_dsn(env: Mapping[str, str]) -> str:
|
||||||
|
"""读取 Postgres DSN 并剥驱动后缀。
|
||||||
|
|
||||||
|
env 路在此剥干净,构造期那道就无事可做——三项目 `.env` 里的 SQLAlchemy
|
||||||
|
写法不会每次装配都刷一条 warning。
|
||||||
|
"""
|
||||||
|
_, dsn = _require(env, "PGW_TELEMETRY_PG_DSN")
|
||||||
|
return _strip_dsn_driver(dsn)
|
||||||
|
|
||||||
|
|
||||||
def _load_cache_keys(
|
def _load_cache_keys(
|
||||||
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
env: Mapping[str, str], cache_backend: str, redis_url: str | None
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
@@ -399,6 +492,13 @@ class EmbeddingSettings:
|
|||||||
normalize: bool = False
|
normalize: bool = False
|
||||||
expected_dim: int | None = None
|
expected_dim: int | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""自身字段的域校验;内嵌的 gateway 由 `GatewaySettings.__post_init__` 自己把关。"""
|
||||||
|
if self.batch_size < 1:
|
||||||
|
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
|
||||||
|
if self.expected_dim is not None and self.expected_dim < 1:
|
||||||
|
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -268,7 +268,11 @@ class EmbeddingClient:
|
|||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
operation="embedding",
|
operation="embedding",
|
||||||
)
|
)
|
||||||
actual = result.prompt_tokens
|
if result.usage_source == "unavailable":
|
||||||
|
# 与 RetryMW 同口径: 用量不可得时按入场预扣量结算(设计 §3.2 #9)
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
|
else:
|
||||||
|
actual = result.prompt_tokens
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
latency_ms = int((self._now() - started) * 1000)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
@@ -291,7 +295,8 @@ class EmbeddingClient:
|
|||||||
reasons[source.name] = reason
|
reasons[source.name] = reason
|
||||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||||
if not dead:
|
if not dead:
|
||||||
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款)
|
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||||
return _FailedBatch(exc, immediate=dead)
|
return _FailedBatch(exc, immediate=dead)
|
||||||
finally:
|
finally:
|
||||||
@@ -380,14 +385,21 @@ class EmbeddingClient:
|
|||||||
vectors = [_l2_normalize(v) for v in vectors]
|
vectors = [_l2_normalize(v) for v in vectors]
|
||||||
first = outcomes[0]
|
first = outcomes[0]
|
||||||
prompt_tokens = sum(o.result.prompt_tokens for o in outcomes)
|
prompt_tokens = sum(o.result.prompt_tokens for o in outcomes)
|
||||||
estimated = any(o.result.usage_source == "estimated" for o in outcomes)
|
# 三态合并优先级(解耦设计 §3.2 #10): 任一批不可得 → 整体不可得
|
||||||
|
sources = {o.result.usage_source for o in outcomes}
|
||||||
|
if "unavailable" in sources:
|
||||||
|
merged_source = "unavailable"
|
||||||
|
elif "estimated" in sources:
|
||||||
|
merged_source = "estimated"
|
||||||
|
else:
|
||||||
|
merged_source = "measured"
|
||||||
return EmbeddingResponse(
|
return EmbeddingResponse(
|
||||||
vectors=vectors,
|
vectors=vectors,
|
||||||
dim=first.result.dim,
|
dim=first.result.dim,
|
||||||
model=first.source.model,
|
model=first.source.model,
|
||||||
provider=first.source.provider,
|
provider=first.source.provider,
|
||||||
prompt_tokens=prompt_tokens,
|
prompt_tokens=prompt_tokens,
|
||||||
usage_source="estimated" if estimated else "measured",
|
usage_source=merged_source,
|
||||||
latency_ms=sum(o.latency_ms for o in outcomes),
|
latency_ms=sum(o.latency_ms for o in outcomes),
|
||||||
call_id=first.call_id,
|
call_id=first.call_id,
|
||||||
source_name=first.source.name,
|
source_name=first.source.name,
|
||||||
@@ -395,8 +407,15 @@ class EmbeddingClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None:
|
def _total_cost(self, outcomes: list[_BatchOutcome]) -> float | None:
|
||||||
|
"""全批成本;任一批用量不可得则整体记 NULL(解耦设计 §3.2 #11)。
|
||||||
|
|
||||||
|
逐批求和会把不可得的批当 0 计入,给出一个偏低却看似有效的金额——
|
||||||
|
与"宁可算不出成本,也不算错成本"的不变式相悖。
|
||||||
|
"""
|
||||||
if self._pricing is None:
|
if self._pricing is None:
|
||||||
return None
|
return None
|
||||||
|
if any(o.result.usage_source == "unavailable" for o in outcomes):
|
||||||
|
return None
|
||||||
costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes]
|
costs = [self._pricing.cost(o.source.model, o.result.prompt_tokens, 0) for o in outcomes]
|
||||||
known = [c for c in costs if c is not None]
|
known = [c for c in costs if c is not None]
|
||||||
return sum(known) if known else None
|
return sum(known) if known else None
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class QuotaGate:
|
|||||||
|
|
||||||
async def try_acquire(self, source: SourceConfig) -> Permit | None:
|
async def try_acquire(self, source: SourceConfig) -> Permit | None:
|
||||||
try:
|
try:
|
||||||
return await self._limiter.try_acquire(source.name, source.est_tokens)
|
return await self._limiter.try_acquire(source.name, source.effective_est_tokens())
|
||||||
except GovernanceBackendError:
|
except GovernanceBackendError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -335,7 +335,12 @@ class RetryMW:
|
|||||||
overlay=request.overlay,
|
overlay=request.overlay,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
)
|
)
|
||||||
actual = result.prompt_tokens + result.completion_tokens
|
if result.usage_source == "unavailable":
|
||||||
|
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
||||||
|
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
|
else:
|
||||||
|
actual = result.prompt_tokens + result.completion_tokens
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
self._feed_outcome(source.name, ok=True)
|
self._feed_outcome(source.name, ok=True)
|
||||||
@@ -367,7 +372,8 @@ class RetryMW:
|
|||||||
self._pacer.on_backpressure(source.name)
|
self._pacer.on_backpressure(source.name)
|
||||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||||
if not dead:
|
if not dead:
|
||||||
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款)
|
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
await self._emit(request, source, call_id, started, error=exc)
|
await self._emit(request, source, call_id, started, error=exc)
|
||||||
return _Failed(exc, immediate=dead)
|
return _Failed(exc, immediate=dead)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TelemetryEmitter:
|
|||||||
response: LLMResponse | None,
|
response: LLMResponse | None,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次尝试记录(RetryMW 调用);失败尝试 usage 按 estimated 记 0。"""
|
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
|
||||||
await self._record(
|
await self._record(
|
||||||
request=request,
|
request=request,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
@@ -55,7 +55,7 @@ class TelemetryEmitter:
|
|||||||
thinking=response.thinking if response else "",
|
thinking=response.thinking if response else "",
|
||||||
prompt_tokens=response.prompt_tokens if response else 0,
|
prompt_tokens=response.prompt_tokens if response else 0,
|
||||||
completion_tokens=response.completion_tokens if response else 0,
|
completion_tokens=response.completion_tokens if response else 0,
|
||||||
usage_source=response.usage_source if response else "estimated",
|
usage_source=response.usage_source if response else "unavailable",
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
ttft_ms=response.ttft_ms if response else None,
|
ttft_ms=response.ttft_ms if response else None,
|
||||||
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,
|
||||||
@@ -97,7 +97,7 @@ class TelemetryEmitter:
|
|||||||
thinking="",
|
thinking="",
|
||||||
prompt_tokens=0,
|
prompt_tokens=0,
|
||||||
completion_tokens=0,
|
completion_tokens=0,
|
||||||
usage_source="estimated",
|
usage_source="unavailable",
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
@@ -129,6 +129,10 @@ class TelemetryEmitter:
|
|||||||
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
|
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
|
||||||
if cache_hit:
|
if cache_hit:
|
||||||
cost: float | None = 0.0
|
cost: float | None = 0.0
|
||||||
|
elif usage_source == "unavailable":
|
||||||
|
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
|
||||||
|
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||||
|
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)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -138,12 +138,31 @@ def _strip_think(content: str) -> tuple[str, str]:
|
|||||||
return _THINK_PATTERN.sub("", content).strip(), match.group(1).strip()
|
return _THINK_PATTERN.sub("", content).strip(), match.group(1).strip()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_usage(usage: dict[str, Any], source: SourceConfig) -> tuple[int, int, str]:
|
def _resolve_usage(usage: dict[str, Any]) -> tuple[int, int, str]:
|
||||||
"""usage 帧读取;缺失/非法按 est_tokens 保守兜底并标 estimated(CHS invokers.py:241)。"""
|
"""usage 帧读取;缺失/非法记 0/0 并标 unavailable(est_tokens 解耦设计 §3.2 #3)。
|
||||||
|
|
||||||
|
不再拿 `est_tokens` 兜底: 它按 CHS 定义是"最坏情形上界",拿上界当实测值
|
||||||
|
只会系统性高估账单;宁可把用量记成显式的"不可得"(cost 随之为 NULL),
|
||||||
|
让缺口可被统计,也不编一个看似有效的数字。用量口径自此不依赖源配置,
|
||||||
|
故不再收 `SourceConfig`。
|
||||||
|
"""
|
||||||
prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens")
|
prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens")
|
||||||
if isinstance(prompt, int) and isinstance(completion, int) and prompt + completion > 0:
|
if isinstance(prompt, int) and isinstance(completion, int) and prompt + completion > 0:
|
||||||
return prompt, completion, "measured"
|
return prompt, completion, "measured"
|
||||||
return 0, source.est_tokens, "estimated"
|
return 0, 0, "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
|
||||||
|
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
|
||||||
|
|
||||||
|
前置条件不可省(解耦设计 §3.2 #4): usage 帧本就缺失时 `0/0` 会被洗成
|
||||||
|
`estimated`,进而按 token 换算出一个假的 `0.0` 成本。
|
||||||
|
"""
|
||||||
|
prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {})
|
||||||
|
if salvaged and usage_source == "measured":
|
||||||
|
# 收到 usage 帧但流被截断: 数字真实、可信度降级(M1 设计 §6)
|
||||||
|
usage_source = "estimated"
|
||||||
|
return prompt, completion, usage_source
|
||||||
|
|
||||||
|
|
||||||
def _extract_vectors(
|
def _extract_vectors(
|
||||||
@@ -168,12 +187,12 @@ def _extract_vectors(
|
|||||||
return vectors
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
def _resolve_embedding_usage(data: dict[str, Any], source: SourceConfig) -> tuple[int, str]:
|
def _resolve_embedding_usage(data: dict[str, Any]) -> tuple[int, str]:
|
||||||
"""usage 读取;缺失/非法按 est_tokens 保守兜底并标 estimated(与 chat 同口径)。"""
|
"""usage 读取;缺失/非法记 0 并标 unavailable(与 chat 同口径,设计 §3.2 #3)。"""
|
||||||
prompt = (data.get("usage") or {}).get("prompt_tokens")
|
prompt = (data.get("usage") or {}).get("prompt_tokens")
|
||||||
if isinstance(prompt, int) and prompt > 0:
|
if isinstance(prompt, int) and prompt > 0:
|
||||||
return prompt, "measured"
|
return prompt, "measured"
|
||||||
return source.est_tokens, "estimated"
|
return 0, "unavailable"
|
||||||
|
|
||||||
|
|
||||||
def _parse_embedding_payload(
|
def _parse_embedding_payload(
|
||||||
@@ -186,7 +205,7 @@ def _parse_embedding_payload(
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ResultInvalidError(f"{source.name} embedding 响应非 JSON: {exc}", **ctx) from exc
|
raise ResultInvalidError(f"{source.name} embedding 响应非 JSON: {exc}", **ctx) from exc
|
||||||
vectors = _extract_vectors(data, source, expected_count, ctx)
|
vectors = _extract_vectors(data, source, expected_count, ctx)
|
||||||
prompt_tokens, usage_source = _resolve_embedding_usage(data, source)
|
prompt_tokens, usage_source = _resolve_embedding_usage(data)
|
||||||
return EmbeddingTransportResult(
|
return EmbeddingTransportResult(
|
||||||
vectors=vectors,
|
vectors=vectors,
|
||||||
dim=len(vectors[0]),
|
dim=len(vectors[0]),
|
||||||
@@ -331,9 +350,7 @@ class OpenAICompatTransport:
|
|||||||
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
|
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
|
||||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {}, source)
|
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
||||||
if salvaged:
|
|
||||||
usage_source = "estimated" # 打捞路径强制 estimated(设计 §6)
|
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
@@ -415,7 +432,7 @@ class OpenAICompatTransport:
|
|||||||
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
|
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
|
||||||
)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
self._reject_empty_completion(content, source)
|
||||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}, source)
|
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ from typing import Any
|
|||||||
|
|
||||||
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
||||||
|
|
||||||
|
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||||
|
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
|
||||||
|
|
||||||
|
_EST_TOKENS_QUOTA_DIVISOR = 60
|
||||||
|
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
@@ -107,6 +113,14 @@ class SourceConfig:
|
|||||||
self._validate_gates()
|
self._validate_gates()
|
||||||
self._validate_watchdog()
|
self._validate_watchdog()
|
||||||
|
|
||||||
|
def effective_est_tokens(self) -> int:
|
||||||
|
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
|
||||||
|
if self.est_tokens > 0:
|
||||||
|
return self.est_tokens
|
||||||
|
if self.tpm > 0:
|
||||||
|
return max(1, self.tpm // _EST_TOKENS_QUOTA_DIVISOR)
|
||||||
|
return 0
|
||||||
|
|
||||||
def _validate_identity(self) -> None:
|
def _validate_identity(self) -> None:
|
||||||
for attr in ("name", "provider", "base_url", "api_key", "model"):
|
for attr in ("name", "provider", "base_url", "api_key", "model"):
|
||||||
if not getattr(self, attr).strip():
|
if not getattr(self, attr).strip():
|
||||||
@@ -122,8 +136,8 @@ class SourceConfig:
|
|||||||
for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"):
|
for attr in ("max_concurrency", "rpm", "tpm", "est_tokens"):
|
||||||
if getattr(self, attr) < 0:
|
if getattr(self, attr) < 0:
|
||||||
raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)")
|
raise ValueError(f"SourceConfig.{attr} 不能为负(0 表示不启用)")
|
||||||
if self.tpm > 0 and self.est_tokens <= 0:
|
# 注: 不再强制 `tpm > 0 ⇒ est_tokens > 0`——预扣量由 effective_est_tokens()
|
||||||
raise ValueError("启用 TPM 闸时 est_tokens 必须 > 0(入场预扣依据)")
|
# 自 tpm 派生,运维只需填供应商配额页上抄得到的 tpm(设计 §3.2 #1)
|
||||||
|
|
||||||
def _validate_watchdog(self) -> None:
|
def _validate_watchdog(self) -> None:
|
||||||
# CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s
|
# CHS config.py:66-82: 流式看门狗成对配置且 0 < inter < ttft < timeout_s
|
||||||
@@ -270,7 +284,7 @@ class EmbeddingTransportResult:
|
|||||||
vectors: list[list[float]]
|
vectors: list[list[float]]
|
||||||
dim: int
|
dim: int
|
||||||
prompt_tokens: int
|
prompt_tokens: int
|
||||||
usage_source: str # measured | estimated
|
usage_source: str # measured | estimated | unavailable
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ class TestTpmGate:
|
|||||||
await p2.release()
|
await p2.release()
|
||||||
assert (await limiter.source_stats("s1")).tpm_used == 600
|
assert (await limiter.source_stats("s1")).tpm_used == 600
|
||||||
|
|
||||||
|
async def test_settle_equal_to_prededuct_keeps_deposit(self, limiter_factory):
|
||||||
|
"""预扣量与结算量同为派生值时,双后端都必须留存押金(delta==0)。
|
||||||
|
|
||||||
|
这里只锁**后端算术**: 相等的两个数进出,窗口残留量恰为该值。
|
||||||
|
"调用点是否真的取了派生值"是编排行为,由 tests/unit/test_retry.py
|
||||||
|
经 RetryMW 端到端覆盖,不在本契约文件重复(否则只是自证同一个入参)。
|
||||||
|
"""
|
||||||
|
src = make_source(tpm=6000, est_tokens=0) # 派生值 = max(1, 6000 // 60) = 100
|
||||||
|
derived = src.effective_est_tokens()
|
||||||
|
assert derived == 100
|
||||||
|
limiter = limiter_factory([src], _NO_GLOBAL)
|
||||||
|
permit = await limiter.try_acquire("s1", derived)
|
||||||
|
assert permit is not None
|
||||||
|
await permit.settle(derived)
|
||||||
|
await permit.release()
|
||||||
|
assert (await limiter.source_stats("s1")).tpm_used == derived
|
||||||
|
|
||||||
async def test_failed_acquire_leaves_no_tpm_trace(self, limiter_factory):
|
async def test_failed_acquire_leaves_no_tpm_trace(self, limiter_factory):
|
||||||
src = make_source(tpm=500, est_tokens=400)
|
src = make_source(tpm=500, est_tokens=400)
|
||||||
limiter = limiter_factory([src], _NO_GLOBAL)
|
limiter = limiter_factory([src], _NO_GLOBAL)
|
||||||
|
|||||||
+208
-1
@@ -1,11 +1,13 @@
|
|||||||
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
"""config.py 配置聚合测试(设计 §8): 多源命名、键优先级、缺失报错。"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.client import GatewayClient
|
from polygateway.client import GatewayClient
|
||||||
from polygateway.config import GatewaySettings, OcrSettings
|
from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||||
|
|
||||||
_BASE_ENV = {
|
_BASE_ENV = {
|
||||||
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
"LLM__QWEN__1__BASE_URL": "https://gw-a.example/v1",
|
||||||
@@ -22,6 +24,17 @@ _BASE_ENV = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _captured_warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
yield messages
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
|
||||||
|
|
||||||
def _env(**overrides):
|
def _env(**overrides):
|
||||||
env = dict(_BASE_ENV)
|
env = dict(_BASE_ENV)
|
||||||
env.update({k: v for k, v in overrides.items() if v is not None})
|
env.update({k: v for k, v in overrides.items() if v is not None})
|
||||||
@@ -158,6 +171,11 @@ class TestAssemblyGuards:
|
|||||||
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
|
s2 = GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="0"))
|
||||||
assert s2.structured_max_retries == 0
|
assert s2.structured_max_retries == 0
|
||||||
|
|
||||||
|
def test_negative_structured_retries_rejected_with_env_key(self):
|
||||||
|
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
|
||||||
|
with pytest.raises(ValueError, match="PGW_STRUCTURED_MAX_RETRIES"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(PGW_STRUCTURED_MAX_RETRIES="-1"))
|
||||||
|
|
||||||
def test_cache_requires_namespace_and_ttl(self):
|
def test_cache_requires_namespace_and_ttl(self):
|
||||||
env = _env(PGW_CACHE_BACKEND="memory")
|
env = _env(PGW_CACHE_BACKEND="memory")
|
||||||
with pytest.raises(ValueError, match="NAMESPACE"):
|
with pytest.raises(ValueError, match="NAMESPACE"):
|
||||||
@@ -258,6 +276,11 @@ class TestAssemblyGuards:
|
|||||||
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
with pytest.raises(ValueError, match="TELEMETRY_BACKEND"):
|
||||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_BACKEND="mysql"))
|
||||||
|
|
||||||
|
def test_cache_backend_whitelist(self):
|
||||||
|
"""对称于上一条: env 层的域检查保留是为了报错能点出键名,得有测试守着。"""
|
||||||
|
with pytest.raises(ValueError, match="CACHE_BACKEND"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(PGW_CACHE_BACKEND="rediss"))
|
||||||
|
|
||||||
def test_pricing_path_optional(self):
|
def test_pricing_path_optional(self):
|
||||||
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
assert GatewaySettings.from_env("LLM", env=_env()).pricing_path is None
|
||||||
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
s = GatewaySettings.from_env("LLM", env=_env(PGW_PRICING_PATH="conf/prices.json"))
|
||||||
@@ -413,3 +436,187 @@ class TestCrossFieldInvariants:
|
|||||||
base = self._base()
|
base = self._base()
|
||||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
|
OcrSettings(gateway=dataclasses.replace(base, lease_ttl_s=1.0))
|
||||||
|
|
||||||
|
# —— 第二轮(设计 2026-07-30): 后端枚举合法域 ——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "bad_value"),
|
||||||
|
[
|
||||||
|
("limiter_backend", "rediss"),
|
||||||
|
("breaker_backend", "sqlite"),
|
||||||
|
("cache_backend", "postgres"),
|
||||||
|
("telemetry_backend", "redis"),
|
||||||
|
("selector", "random"),
|
||||||
|
("quota_full", "block"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
|
||||||
|
"""域外取值此前只有 from_env 拦得住,直接构造会落进 _build_* 的 else 分支。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match=field):
|
||||||
|
dataclasses.replace(base, **{field: bad_value})
|
||||||
|
|
||||||
|
# —— 条件必填: 取 redis 的后端必须有 redis_url ——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["limiter_backend", "breaker_backend"])
|
||||||
|
def test_redis_backend_requires_redis_url(self, field):
|
||||||
|
"""client.py 的 assert settings.redis_url is not None 依赖的正是这条。"""
|
||||||
|
base = self._base() # redis_url=None
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, **{field: "redis"})
|
||||||
|
|
||||||
|
def test_redis_cache_requires_redis_url(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, cache_backend="redis", cache_namespace="ns", cache_ttl_s=60)
|
||||||
|
|
||||||
|
# —— 条件必填: 启用缓存必须有命名空间与正 TTL ——
|
||||||
|
|
||||||
|
def test_cache_requires_namespace(self):
|
||||||
|
"""缺命名空间即失去租户隔离,踩"无缓存毒化"铁律。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="cache_namespace"):
|
||||||
|
dataclasses.replace(base, cache_backend="memory", cache_ttl_s=60)
|
||||||
|
|
||||||
|
def test_cache_ttl_must_be_positive(self):
|
||||||
|
"""from_env 明令禁止的"永不过期"不能从另一条路进来。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="cache_ttl_s"):
|
||||||
|
dataclasses.replace(base, cache_backend="memory", cache_namespace="ns", cache_ttl_s=0)
|
||||||
|
|
||||||
|
# —— 条件必填: 遥测后端各自的落点 ——
|
||||||
|
|
||||||
|
def test_sqlite_telemetry_requires_path(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="telemetry_sqlite_path"):
|
||||||
|
dataclasses.replace(base, telemetry_backend="sqlite")
|
||||||
|
|
||||||
|
def test_postgres_telemetry_requires_dsn(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
|
||||||
|
dataclasses.replace(base, telemetry_backend="postgres")
|
||||||
|
|
||||||
|
# —— 标量域 ——
|
||||||
|
|
||||||
|
def test_negative_structured_retries_rejected(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="structured_max_retries"):
|
||||||
|
dataclasses.replace(base, structured_max_retries=-1)
|
||||||
|
|
||||||
|
def test_blank_scope_rejected(self):
|
||||||
|
"""空 scope 会污染遥测与缓存命名空间。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="scope"):
|
||||||
|
dataclasses.replace(base, scope=" ")
|
||||||
|
|
||||||
|
# —— 合法组合仍可构造(收紧的是错的那些)——
|
||||||
|
|
||||||
|
def test_full_redis_stack_constructible(self):
|
||||||
|
base = self._base()
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
limiter_backend="redis",
|
||||||
|
breaker_backend="redis",
|
||||||
|
cache_backend="redis",
|
||||||
|
cache_namespace="ns",
|
||||||
|
cache_ttl_s=60,
|
||||||
|
redis_url="redis://127.0.0.1:6379/3",
|
||||||
|
)
|
||||||
|
assert settings.cache_ttl_s == 60 and settings.redis_url is not None
|
||||||
|
|
||||||
|
# —— Postgres DSN: 剥 SQLAlchemy 驱动后缀并出声(设计 §5 方案 C)——
|
||||||
|
|
||||||
|
def test_sqlalchemy_dsn_suffix_stripped_with_warning(self):
|
||||||
|
"""asyncpg 不认 `+driver`;库替调用方剥掉,但不静默——日志里看得见。"""
|
||||||
|
base = self._base()
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
telemetry_backend="postgres",
|
||||||
|
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
|
||||||
|
)
|
||||||
|
assert settings.telemetry_pg_dsn == "postgresql://u:s3cret@h/db"
|
||||||
|
assert any("asyncpg" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_dsn_warning_does_not_leak_credentials(self):
|
||||||
|
"""DSN 带密码,日志只能出现 scheme 段(P5: 敏感信息只走 .env)。"""
|
||||||
|
base = self._base()
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
dataclasses.replace(
|
||||||
|
base,
|
||||||
|
telemetry_backend="postgres",
|
||||||
|
telemetry_pg_dsn="postgresql+asyncpg://u:s3cret@h/db",
|
||||||
|
)
|
||||||
|
assert warnings and not any("s3cret" in m or "@h/db" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_env_path_strips_dsn_without_warning(self):
|
||||||
|
"""env 路已在 _load_pg_dsn 剥过,不该给三项目的历史 DSN 写法刷噪音。"""
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
settings = GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=_env(
|
||||||
|
PGW_TELEMETRY_BACKEND="postgres",
|
||||||
|
PGW_TELEMETRY_PG_DSN="postgresql+asyncpg://u@h/db",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert settings.telemetry_pg_dsn == "postgresql://u@h/db"
|
||||||
|
assert not warnings
|
||||||
|
|
||||||
|
# —— 构造期规范化: env 路一直在做的,构造路也要做(否则两条路产出不同的值)——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["LLM", " llm ", " LLM "])
|
||||||
|
def test_scope_normalized_on_direct_construction(self, raw):
|
||||||
|
"""scope 直接进 Redis key(pgw:limit:{scope}:…)。
|
||||||
|
|
||||||
|
大小写不一致会让同一逻辑 scope 的限流/熔断状态分裂到两套命名空间——
|
||||||
|
两边各记各的配额与熔断状态,分布式治理静默失效且不报错。
|
||||||
|
"""
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, scope=raw).scope == "llm"
|
||||||
|
|
||||||
|
def test_blank_redis_url_normalized_to_none(self):
|
||||||
|
"""空串此前只有 env 路归 None,构造路留着它骗过 `is None` 判断。"""
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, redis_url="").redis_url is None
|
||||||
|
|
||||||
|
def test_blank_redis_url_still_blocks_redis_backend(self):
|
||||||
|
"""归 None 后必须落进条件必填,而不是放行到 redis 库去抛连接串天书。"""
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="redis_url"):
|
||||||
|
dataclasses.replace(base, limiter_backend="redis", redis_url="")
|
||||||
|
|
||||||
|
def test_blank_pricing_path_normalized_to_none(self):
|
||||||
|
base = self._base()
|
||||||
|
assert dataclasses.replace(base, pricing_path="").pricing_path is None
|
||||||
|
|
||||||
|
# —— EmbeddingSettings 自身的字段域(此前只有 from_env 校验)——
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [0, -3])
|
||||||
|
def test_embedding_settings_rejects_non_positive_batch_size(self, bad):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="batch_size"):
|
||||||
|
EmbeddingSettings(gateway=base, batch_size=bad)
|
||||||
|
|
||||||
|
def test_embedding_settings_rejects_non_positive_expected_dim(self):
|
||||||
|
base = self._base()
|
||||||
|
with pytest.raises(ValueError, match="expected_dim"):
|
||||||
|
EmbeddingSettings(gateway=base, batch_size=8, expected_dim=0)
|
||||||
|
|
||||||
|
def test_embedding_settings_accepts_valid_values(self):
|
||||||
|
base = self._base()
|
||||||
|
settings = EmbeddingSettings(gateway=base, batch_size=8, expected_dim=1024)
|
||||||
|
assert settings.batch_size == 8 and settings.expected_dim == 1024
|
||||||
|
|
||||||
|
# —— 回归护栏: client.py 的 assert 前提确实被保证了 ——
|
||||||
|
|
||||||
|
def test_factory_accepts_valid_redis_stack(self):
|
||||||
|
"""补齐校验后,client.py:262/282/302 的 assert 退回成纯内部不变量声明。"""
|
||||||
|
base = self._base()
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
base,
|
||||||
|
limiter_backend="redis",
|
||||||
|
breaker_backend="redis",
|
||||||
|
redis_url="redis://127.0.0.1:6379/3",
|
||||||
|
)
|
||||||
|
client = GatewayClient.from_settings(settings)
|
||||||
|
assert client is not None
|
||||||
|
|||||||
@@ -102,12 +102,14 @@ class TestEmbedTransport:
|
|||||||
assert result.dim == 2
|
assert result.dim == 2
|
||||||
assert result.prompt_tokens == 5 and result.usage_source == "measured"
|
assert result.prompt_tokens == 5 and result.usage_source == "measured"
|
||||||
|
|
||||||
async def test_missing_usage_falls_back_estimated(self):
|
async def test_missing_usage_is_unavailable(self):
|
||||||
|
"""usage 缺失不再退到 `est_tokens`(夹具填 7),与 chat 同口径记 0 + unavailable。"""
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return httpx.Response(200, json=_ok_body([[1.0]]))
|
return httpx.Response(200, json=_ok_body([[1.0]]))
|
||||||
|
|
||||||
result = await _transport_with(handler).embed(texts=["a"], source=_src(), call_id="c")
|
result = await _transport_with(handler).embed(texts=["a"], source=_src(), call_id="c")
|
||||||
assert result.prompt_tokens == 7 and result.usage_source == "estimated" # est_tokens
|
assert result.prompt_tokens == 0 and result.usage_source == "unavailable"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("status", "exc_type"),
|
("status", "exc_type"),
|
||||||
@@ -161,6 +163,7 @@ from polygateway.backends.memory.breaker import InMemoryGate # noqa: E402
|
|||||||
from polygateway.backends.memory.limiter import InMemoryLimiter # noqa: E402
|
from polygateway.backends.memory.limiter import InMemoryLimiter # noqa: E402
|
||||||
from polygateway.config import EmbeddingSettings # noqa: E402
|
from polygateway.config import EmbeddingSettings # noqa: E402
|
||||||
from polygateway.embedding import EmbeddingClient # noqa: E402
|
from polygateway.embedding import EmbeddingClient # noqa: E402
|
||||||
|
from polygateway.pricing import ModelPrice, PricingTable # noqa: E402
|
||||||
from polygateway.sources import RoundRobinSelector # noqa: E402
|
from polygateway.sources import RoundRobinSelector # noqa: E402
|
||||||
from polygateway.types import ( # noqa: E402
|
from polygateway.types import ( # noqa: E402
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
@@ -260,6 +263,28 @@ class TestEmbedBatching:
|
|||||||
assert resp.usage_source == "estimated" # 任一批 estimated 则整体 estimated
|
assert resp.usage_source == "estimated" # 任一批 estimated 则整体 estimated
|
||||||
assert resp.prompt_tokens == 2 + 9
|
assert resp.prompt_tokens == 2 + 9
|
||||||
|
|
||||||
|
async def test_unavailable_batch_dominates_and_voids_cost(self):
|
||||||
|
"""三态合并优先级(设计 §3.2 #10/#11): 任一批不可得 → 整体不可得且 cost NULL。
|
||||||
|
|
||||||
|
改前二值合并只看 `estimated`,measured+unavailable 会误标 measured;
|
||||||
|
`_total_cost` 逐批求和还会给出一个偏低却看似有效的金额。
|
||||||
|
"""
|
||||||
|
estimated = EmbeddingTransportResult(
|
||||||
|
vectors=[[1.0], [1.0]], dim=1, prompt_tokens=9, usage_source="estimated", raw={}
|
||||||
|
)
|
||||||
|
unavailable = EmbeddingTransportResult(
|
||||||
|
vectors=[[1.0], [1.0]], dim=1, prompt_tokens=0, usage_source="unavailable", raw={}
|
||||||
|
)
|
||||||
|
client, _ = _embed_client(
|
||||||
|
[_src()],
|
||||||
|
["ok", estimated, unavailable],
|
||||||
|
batch_size=2,
|
||||||
|
pricing=PricingTable({"embed-1": ModelPrice(input_per_1m=1.0, output_per_1m=0.0)}),
|
||||||
|
)
|
||||||
|
resp = await client.embed(["a", "b", "c", "d", "e", "f"])
|
||||||
|
assert resp.usage_source == "unavailable" # unavailable 压过 estimated 与 measured
|
||||||
|
assert resp.cost is None
|
||||||
|
|
||||||
|
|
||||||
class TestEmbedPostProcess:
|
class TestEmbedPostProcess:
|
||||||
async def test_normalize_l2(self):
|
async def test_normalize_l2(self):
|
||||||
@@ -358,6 +383,11 @@ class TestEmbeddingSettings:
|
|||||||
s = EmbeddingSettings.from_env("EMBED", env=env)
|
s = EmbeddingSettings.from_env("EMBED", env=env)
|
||||||
assert s.normalize is True and s.expected_dim == 768
|
assert s.normalize is True and s.expected_dim == 768
|
||||||
|
|
||||||
|
def test_expected_dim_must_be_positive(self):
|
||||||
|
"""env 层的检查保留是为了报错能点出键名(构造期那道点的是字段名)。"""
|
||||||
|
with pytest.raises(ValueError, match="EXPECTED_DIM"):
|
||||||
|
EmbeddingSettings.from_env("EMBED", env={**self._ENV, "EMBED__EXPECTED_DIM": "0"})
|
||||||
|
|
||||||
def test_from_settings_assembles_client(self):
|
def test_from_settings_assembles_client(self):
|
||||||
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
s = EmbeddingSettings.from_env("EMBED", env=self._ENV)
|
||||||
client = EmbeddingClient.from_settings(s)
|
client = EmbeddingClient.from_settings(s)
|
||||||
|
|||||||
@@ -394,6 +394,21 @@ class TestTelemetry:
|
|||||||
assert recorder.rows[1]["error"] is None
|
assert recorder.rows[1]["error"] is None
|
||||||
assert recorder.rows[1]["prompt_tokens"] == 0
|
assert recorder.rows[1]["prompt_tokens"] == 0
|
||||||
|
|
||||||
|
async def test_success_row_stays_measured_and_settles_zero(self):
|
||||||
|
"""OCR 的 0 token 是**事实**而非未知(est_tokens 解耦设计 §3.3 剔出决定)。
|
||||||
|
|
||||||
|
三态化不得把 OCR 成功行改成 `unavailable`——那会灌水缺口度量
|
||||||
|
`COUNT(*) WHERE usage_source='unavailable'`;settle 恒 0 的差异①同样不动。
|
||||||
|
"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, limiter, _ = _client([_src(tpm=1000, est_tokens=400)], ["text"], telemetry=recorder)
|
||||||
|
await client.recognize_text(b"jpg")
|
||||||
|
row = recorder.rows[0]
|
||||||
|
assert row["usage_source"] == "measured"
|
||||||
|
assert row["prompt_tokens"] == 0 and row["completion_tokens"] == 0
|
||||||
|
assert row["error"] is None
|
||||||
|
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
|
||||||
|
|
||||||
|
|
||||||
class TestAssembly:
|
class TestAssembly:
|
||||||
_ENV = {
|
_ENV = {
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
from polygateway.transports.openai_compat import (
|
from polygateway.transports.openai_compat import (
|
||||||
OpenAICompatTransport,
|
OpenAICompatTransport,
|
||||||
_iter_sse_deltas,
|
_iter_sse_deltas,
|
||||||
_sse_data_payload,
|
_sse_data_payload,
|
||||||
)
|
)
|
||||||
from polygateway.types import SourceConfig
|
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||||
|
|
||||||
|
|
||||||
def _source(**overrides):
|
def _source(**overrides):
|
||||||
@@ -71,6 +73,53 @@ async def _complete(transport, source, *, stream=True, overlay=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
|
||||||
|
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
|
||||||
|
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
||||||
|
|
||||||
|
|
||||||
|
class _MemoryRecorder:
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = []
|
||||||
|
|
||||||
|
async def record_llm_call(self, **fields):
|
||||||
|
self.rows.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
async def _recorded_cost(result, source):
|
||||||
|
"""把 transport 产物走一遍真实计费路径,返回落库的 cost。
|
||||||
|
|
||||||
|
`unavailable` → cost=None 的判定在 `TelemetryEmitter` 里(设计 §3.2 #5),
|
||||||
|
直接调 `PricingTable.cost` 对 `0/0` 只会得到 `0.0`——那正是本组用例要防的
|
||||||
|
假金额,故断言必须穿过 emitter 而不是单测 pricing。
|
||||||
|
"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
response = LLMResponse(
|
||||||
|
content=result.content,
|
||||||
|
thinking=result.thinking,
|
||||||
|
model=source.model,
|
||||||
|
provider=source.provider,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
latency_ms=1,
|
||||||
|
ttft_ms=result.ttft_ms,
|
||||||
|
max_inter_token_ms=result.max_inter_token_ms,
|
||||||
|
cache_hit=False,
|
||||||
|
call_id="cid-1",
|
||||||
|
source_name=source.name,
|
||||||
|
usage_source=result.usage_source,
|
||||||
|
)
|
||||||
|
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
|
||||||
|
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
|
||||||
|
source=source,
|
||||||
|
call_id="cid-1",
|
||||||
|
latency_ms=1,
|
||||||
|
response=response,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
return recorder.rows[0]["cost"]
|
||||||
|
|
||||||
|
|
||||||
class TestSsePureFunctions:
|
class TestSsePureFunctions:
|
||||||
def test_data_payload_filters_noise(self):
|
def test_data_payload_filters_noise(self):
|
||||||
assert _sse_data_payload("") is None
|
assert _sse_data_payload("") is None
|
||||||
@@ -129,13 +178,21 @@ class TestStreamHappyPath:
|
|||||||
assert result.content == "answer"
|
assert result.content == "answer"
|
||||||
assert result.thinking == "hmm"
|
assert result.thinking == "hmm"
|
||||||
|
|
||||||
async def test_usage_missing_falls_back_to_est(self):
|
async def test_usage_missing_is_unavailable_with_null_cost(self):
|
||||||
|
"""usage 帧缺失 → 0/0 + unavailable + cost NULL(设计 §3.2 #3)。
|
||||||
|
|
||||||
|
改前拿 `est_tokens` 当实测并整估值塞 completion,同一条调用记成
|
||||||
|
`0/4000` → cost 0.032(设计 §1 的 26 倍虚高)。
|
||||||
|
"""
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
return _sse_stream(_chunk(content="ok"))
|
return _sse_stream(_chunk(content="ok"))
|
||||||
|
|
||||||
result = await _complete(_transport_for(handler), _source(tpm=1000, est_tokens=333))
|
source = _source(tpm=1000, est_tokens=4000)
|
||||||
assert result.usage_source == "estimated"
|
result = await _complete(_transport_for(handler), source)
|
||||||
assert result.prompt_tokens == 0 and result.completion_tokens == 333
|
assert result.usage_source == "unavailable"
|
||||||
|
assert result.prompt_tokens == 0 and result.completion_tokens == 0
|
||||||
|
assert await _recorded_cost(result, source) is None
|
||||||
|
|
||||||
|
|
||||||
class TestMissingDoneSemantics:
|
class TestMissingDoneSemantics:
|
||||||
@@ -146,12 +203,28 @@ class TestMissingDoneSemantics:
|
|||||||
with pytest.raises(TransientError, match="missing_done|truncated"):
|
with pytest.raises(TransientError, match="missing_done|truncated"):
|
||||||
await _complete(_transport_for(self._no_done_handler), _source())
|
await _complete(_transport_for(self._no_done_handler), _source())
|
||||||
|
|
||||||
async def test_salvage_policy_keeps_content_as_estimated(self):
|
async def test_salvage_with_usage_frame_degrades_to_estimated(self):
|
||||||
result = await _complete(
|
"""打捞且收到 usage 帧: 数字真实、可信度降级 → estimated 且照常计费。"""
|
||||||
_transport_for(self._no_done_handler), _source(missing_done="salvage")
|
source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
|
||||||
)
|
result = await _complete(_transport_for(self._no_done_handler), source)
|
||||||
assert result.content == "partial"
|
assert result.content == "partial"
|
||||||
assert result.usage_source == "estimated" # 打捞路径强制 estimated
|
assert result.usage_source == "estimated"
|
||||||
|
assert result.prompt_tokens == 11 and result.completion_tokens == 7
|
||||||
|
assert await _recorded_cost(result, source) == pytest.approx(
|
||||||
|
11 / 1_000_000 * 1.0 + 7 / 1_000_000 * 8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_salvage_without_usage_frame_stays_unavailable(self):
|
||||||
|
"""打捞且 usage 帧缺失: 0/0 不得被洗成 estimated,否则算出假的 0.0(设计 §3.2 #4)。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="partial"), done=False)
|
||||||
|
|
||||||
|
source = _source(missing_done="salvage", tpm=1000, est_tokens=4000)
|
||||||
|
result = await _complete(_transport_for(handler), source)
|
||||||
|
assert result.content == "partial"
|
||||||
|
assert result.usage_source == "unavailable"
|
||||||
|
assert await _recorded_cost(result, source) is None
|
||||||
|
|
||||||
async def test_early_eof_always_transient_even_under_salvage(self):
|
async def test_early_eof_always_transient_even_under_salvage(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
|
|||||||
@@ -152,6 +152,47 @@ class TestSuccessPath:
|
|||||||
# 预扣 400,实际 15 → settle 后窗口只记 15
|
# 预扣 400,实际 15 → settle 后窗口只记 15
|
||||||
assert (await limiter.source_stats("a")).tpm_used == 15
|
assert (await limiter.source_stats("a")).tpm_used == 15
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("usage_source", ["measured", "estimated"])
|
||||||
|
async def test_settle_uses_measured_sum_when_usage_available(self, usage_source):
|
||||||
|
"""用量可得(含打捞降级的 estimated)时结算恒取实测之和,不落派生兜底分支。"""
|
||||||
|
src = _src("a", tpm=1000, est_tokens=400)
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=40,
|
||||||
|
completion_tokens=60,
|
||||||
|
usage_source=usage_source,
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
mw, limiter, *_ = _harness([src], [result])
|
||||||
|
await mw(_REQ)
|
||||||
|
# 预扣 400,实测 40+60 → settle 后窗口记 100(而非派生兜底的 400)
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 100
|
||||||
|
|
||||||
|
async def test_settle_keeps_derived_deposit_when_usage_unavailable(self):
|
||||||
|
"""未填 est_tokens + usage 帧缺失的**成功**调用: 押金留存而非整笔退回。
|
||||||
|
|
||||||
|
入场预扣与结算须同取 `effective_est_tokens()`(delta==0),否则对
|
||||||
|
"从不返回 usage 帧"的源等于 TPM 闸进门即放行、出门即清账(设计 §3.2 #9)。
|
||||||
|
"""
|
||||||
|
src = _src("a", tpm=1000, est_tokens=0) # 派生预扣量 = max(1, 1000 // 60) = 16
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=0,
|
||||||
|
completion_tokens=0,
|
||||||
|
usage_source="unavailable",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
mw, limiter, *_ = _harness([src], [result])
|
||||||
|
await mw(_REQ)
|
||||||
|
assert src.effective_est_tokens() == 16
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 16
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
async def test_transient_switches_source_then_succeeds(self):
|
async def test_transient_switches_source_then_succeeds(self):
|
||||||
@@ -202,6 +243,18 @@ class TestRetryAndFailover:
|
|||||||
assert sleep.delays == [] # 源死亡不退避
|
assert sleep.delays == [] # 源死亡不退避
|
||||||
assert not (await gate.try_enter("a", "w")).allowed # a 已 force_open
|
assert not (await gate.try_enter("a", "w")).allowed # a 已 force_open
|
||||||
|
|
||||||
|
async def test_transient_failure_keeps_derived_deposit(self):
|
||||||
|
"""未填 est_tokens 的**非 dead 瞬时失败**同样按派生预扣量保守结算。
|
||||||
|
|
||||||
|
失败请求可能已被网关计费,退掉押金会低估用量(设计 §3.2 #8);
|
||||||
|
max_attempts=1 保证恰一次尝试,窗口残留量即单次预扣量。
|
||||||
|
"""
|
||||||
|
src = _src("a", tpm=1000, est_tokens=0) # 派生预扣量 = 16
|
||||||
|
mw, limiter, *_ = _harness([src], [TransientError("boom")], max_attempts=1)
|
||||||
|
with pytest.raises(AllSourcesExhausted):
|
||||||
|
await mw(_REQ)
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 16
|
||||||
|
|
||||||
|
|
||||||
class TestNonRetryableOutcomes:
|
class TestNonRetryableOutcomes:
|
||||||
async def test_request_rejected_propagates_without_retry(self):
|
async def test_request_rejected_propagates_without_retry(self):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
|
|
||||||
from polygateway.errors import CircuitOpenError, RequestRejectedError
|
from polygateway.errors import CircuitOpenError, RequestRejectedError
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||||
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||||
|
|
||||||
@@ -68,6 +69,10 @@ def _source():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032
|
||||||
|
_PRICING = PricingTable({"m": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
||||||
|
|
||||||
|
|
||||||
async def _record_minimal(recorder, call_id="c1", **overrides):
|
async def _record_minimal(recorder, call_id="c1", **overrides):
|
||||||
fields = {
|
fields = {
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
@@ -168,7 +173,58 @@ class TestEmitter:
|
|||||||
)
|
)
|
||||||
row = rec.rows[0]
|
row = rec.rows[0]
|
||||||
assert row["error"].startswith("TransientError")
|
assert row["error"].startswith("TransientError")
|
||||||
assert row["response"] == "" and row["usage_source"] == "estimated"
|
# 失败尝试没有任何用量信息可言 → unavailable(设计 §3.2 #6)
|
||||||
|
assert row["response"] == "" and row["usage_source"] == "unavailable"
|
||||||
|
assert row["cost"] is None
|
||||||
|
|
||||||
|
async def test_terminal_failure_row_is_unavailable(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=_PRICING).emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="cid-t", latency_ms=5, error="cancelled"
|
||||||
|
)
|
||||||
|
row = rec.rows[0]
|
||||||
|
assert row["usage_source"] == "unavailable" and row["cost"] is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("prompt", "completion"), [(0, 0), (0, 4000)])
|
||||||
|
async def test_unavailable_success_row_has_null_cost(self, prompt, completion):
|
||||||
|
"""产生了真实调用但用量不可得 → cost 记 NULL(设计 §3.1 不变式)。
|
||||||
|
|
||||||
|
参数第二组是改前兜底写出的 `0/4000` 形态: 那时换算出 0.032 的假金额。
|
||||||
|
"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-u",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(
|
||||||
|
usage_source="unavailable", prompt_tokens=prompt, completion_tokens=completion
|
||||||
|
),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|
||||||
|
async def test_measured_row_still_priced(self):
|
||||||
|
"""对照组: 同一价格表下 measured 行照常换算,证明 None 不是价格表没接上。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=_PRICING).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-m",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(prompt_tokens=0, completion_tokens=4000),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] == pytest.approx(0.032)
|
||||||
|
|
||||||
|
async def test_cache_hit_keeps_zero_cost_even_when_unavailable(self):
|
||||||
|
"""缓存命中未产生新调用,0.0 是事实而非未知 → 短路必须排在 cache_hit 之后。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=_PRICING).emit_cache_hit(
|
||||||
|
request=_REQ,
|
||||||
|
response=_resp(cache_hit=True, usage_source="unavailable", completion_tokens=4000),
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cache_hit"] is True and rec.rows[0]["cost"] == 0.0
|
||||||
|
|
||||||
async def test_multimodal_messages_digested_before_storage(self):
|
async def test_multimodal_messages_digested_before_storage(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""types.py 冻结签名的行为测试(M1 设计 §2)。"""
|
"""types.py 冻结签名的行为测试(M1 设计 §2)。"""
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import inspect
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
|
USAGE_SOURCES,
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -91,9 +93,11 @@ class TestSourceConfig:
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
_make_source(timeout_s=0)
|
_make_source(timeout_s=0)
|
||||||
|
|
||||||
def test_tpm_requires_est_tokens(self):
|
def test_tpm_does_not_require_est_tokens(self):
|
||||||
with pytest.raises(ValueError):
|
"""`tpm > 0 ⇒ est_tokens > 0` 已解绑: 供应商配额可独立于库实现细节填写。"""
|
||||||
_make_source(tpm=10000, est_tokens=0)
|
derived = _make_source(tpm=10000, est_tokens=0)
|
||||||
|
assert derived.est_tokens == 0
|
||||||
|
assert derived.effective_est_tokens() == 166 # max(1, 10000 // 60)
|
||||||
assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800
|
assert _make_source(tpm=10000, est_tokens=800).est_tokens == 800
|
||||||
|
|
||||||
def test_negative_gate_rejected(self):
|
def test_negative_gate_rejected(self):
|
||||||
@@ -117,6 +121,70 @@ class TestSourceConfig:
|
|||||||
_make_source(missing_done="ignore")
|
_make_source(missing_done="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveEstTokens:
|
||||||
|
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
|
||||||
|
|
||||||
|
def test_derives_from_tpm_scale_free(self):
|
||||||
|
"""派生量随配额同比缩放: 两种配额规模的在途上限同为 60 个调用。"""
|
||||||
|
assert _make_source(tpm=6000).effective_est_tokens() == 100
|
||||||
|
assert _make_source(tpm=600000).effective_est_tokens() == 10000
|
||||||
|
|
||||||
|
def test_derived_floor_is_one(self):
|
||||||
|
"""极小配额下派生量不得塌到 0——0 预扣等于 TPM 闸不设防(设计 §2.2)。"""
|
||||||
|
assert _make_source(tpm=30).effective_est_tokens() == 1
|
||||||
|
|
||||||
|
def test_zero_when_tpm_gate_disabled(self):
|
||||||
|
"""tpm=0 即 TPM 闸未启用,无需预扣。"""
|
||||||
|
assert _make_source().effective_est_tokens() == 0
|
||||||
|
|
||||||
|
def test_explicit_value_wins(self):
|
||||||
|
"""显式配置是调优覆盖,优先于派生。"""
|
||||||
|
assert _make_source(tpm=6000, est_tokens=4000).effective_est_tokens() == 4000
|
||||||
|
|
||||||
|
def test_is_pure_sync_function(self):
|
||||||
|
"""纯方法: 非协程、可重复调用、不改动自身字段(设计 §5 并发前提)。"""
|
||||||
|
assert not inspect.iscoroutinefunction(SourceConfig.effective_est_tokens)
|
||||||
|
src = _make_source(tpm=6000)
|
||||||
|
assert src.effective_est_tokens() == src.effective_est_tokens() == 100
|
||||||
|
assert src.est_tokens == 0 # 派生不回写字段
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsageSourceDomain:
|
||||||
|
"""`usage_source` 三态值域常量(设计 §3.1)。"""
|
||||||
|
|
||||||
|
def test_domain_is_exactly_three_values(self):
|
||||||
|
assert set(USAGE_SOURCES) == {"measured", "estimated", "unavailable"}
|
||||||
|
assert isinstance(USAGE_SOURCES, frozenset) # 不可变: 调用方无法就地扩张值域
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"build",
|
||||||
|
[
|
||||||
|
lambda v: LLMResponse(
|
||||||
|
"c", "t", "m", "p", 1, 2, 3, None, None, False, "cid", usage_source=v
|
||||||
|
),
|
||||||
|
lambda v: Usage(prompt_tokens=1, completion_tokens=2, usage_source=v),
|
||||||
|
lambda v: TransportResult(
|
||||||
|
content="c",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=1,
|
||||||
|
completion_tokens=2,
|
||||||
|
usage_source=v,
|
||||||
|
ttft_ms=None,
|
||||||
|
max_inter_token_ms=None,
|
||||||
|
raw={},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_no_runtime_validation_on_public_dataclasses(self, build):
|
||||||
|
"""越界值构造**不得**抛异常(锁定设计 §3.1 的落点裁决)。
|
||||||
|
|
||||||
|
这些是运行时构造点(如 retry.py:418),裸 `ValueError` 不属 errors.py
|
||||||
|
四分类、`RetryMW` 不捕它,会直接逃出 `chat()`——故值域只约束生产侧,
|
||||||
|
不落在公共 frozen dataclass 的 `__post_init__` 上。
|
||||||
|
"""
|
||||||
|
assert build("garbage").usage_source == "garbage"
|
||||||
|
|
||||||
|
|
||||||
class TestResilienceConfigs:
|
class TestResilienceConfigs:
|
||||||
def test_retry_policy_validation(self):
|
def test_retry_policy_validation(self):
|
||||||
assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0)
|
assert RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0)
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""`usage_source` 值域封闭: 库内所有生产点的产出恒落在 `USAGE_SOURCES` 内。
|
||||||
|
|
||||||
|
设计 §3.1 裁定值域**只约束生产侧**——公共 frozen dataclass 不加运行时校验
|
||||||
|
(裸 `ValueError` 不属四分类,会逃出 `chat()`;该裁决的锁定断言在
|
||||||
|
`test_types.py::TestUsageSourceDomain`)。因此封闭性只能由"逐个驱动生产点、
|
||||||
|
断言其产出在三态内"来保证,本文件即该断言的载体。
|
||||||
|
|
||||||
|
独立成文件而非并入 `test_types.py`: 断言横跨 transports / embedding /
|
||||||
|
telemetry 三层,放进最内层内核的类型测试会让它反向依赖具体实现。
|
||||||
|
|
||||||
|
覆盖的生产点(设计 §3.2 逐处改动表的字面量产出方):
|
||||||
|
`_resolve_usage`、`_resolve_embedding_usage`、`_resolve_stream_usage`(打捞覆盖)、
|
||||||
|
`EmbeddingClient._merge`、`EmbeddingClient.embed` 空输入短路、
|
||||||
|
`OcrClient._emit`、`TelemetryEmitter.emit_attempt/emit_cache_hit/emit_terminal_failure`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
|
from polygateway.embedding import EmbeddingClient, _BatchOutcome
|
||||||
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
|
from polygateway.ocr import OcrClient
|
||||||
|
from polygateway.sources import RoundRobinSelector
|
||||||
|
from polygateway.transports.openai_compat import (
|
||||||
|
OpenAICompatTransport,
|
||||||
|
_resolve_embedding_usage,
|
||||||
|
_resolve_usage,
|
||||||
|
)
|
||||||
|
from polygateway.types import (
|
||||||
|
USAGE_SOURCES,
|
||||||
|
BackpressurePolicy,
|
||||||
|
BreakerConfig,
|
||||||
|
ChatRequest,
|
||||||
|
EmbeddingTransportResult,
|
||||||
|
GlobalLimits,
|
||||||
|
LLMResponse,
|
||||||
|
OcrTextTransportResult,
|
||||||
|
RetryPolicy,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
_REQ = ChatRequest(messages=[{"role": "user", "content": "hi"}])
|
||||||
|
_DOMAIN = sorted(USAGE_SOURCES)
|
||||||
|
|
||||||
|
|
||||||
|
def _src():
|
||||||
|
return SourceConfig(
|
||||||
|
name="s1",
|
||||||
|
provider="p",
|
||||||
|
base_url="https://gw.example/v1",
|
||||||
|
api_key="sk",
|
||||||
|
model="m",
|
||||||
|
timeout_s=10.0,
|
||||||
|
est_tokens=4000, # 兜底口径的历史来源: 生产点不得因它落到三态之外
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _MemoryRecorder:
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = []
|
||||||
|
|
||||||
|
async def record_llm_call(self, **fields):
|
||||||
|
self.rows.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"usage",
|
||||||
|
[
|
||||||
|
{"prompt_tokens": 12, "completion_tokens": 34}, # 完整可信
|
||||||
|
{}, # 整帧缺失
|
||||||
|
{"prompt_tokens": 0, "completion_tokens": 0}, # 全 0(和不为正)
|
||||||
|
{"prompt_tokens": "12", "completion_tokens": 34}, # 类型非法
|
||||||
|
{"prompt_tokens": None, "completion_tokens": None},
|
||||||
|
{"prompt_tokens": 12}, # 半帧
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_usage_stays_in_domain(usage):
|
||||||
|
assert _resolve_usage(usage)[2] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"data",
|
||||||
|
[
|
||||||
|
{"usage": {"prompt_tokens": 12}},
|
||||||
|
{},
|
||||||
|
{"usage": None},
|
||||||
|
{"usage": {}},
|
||||||
|
{"usage": {"prompt_tokens": 0}},
|
||||||
|
{"usage": {"prompt_tokens": "12"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_embedding_usage_stays_in_domain(data):
|
||||||
|
assert _resolve_embedding_usage(data)[1] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(*frames, done):
|
||||||
|
"""构造 SSE 响应;done=False 触发打捞路径(`_complete_stream` 的覆盖分支)。"""
|
||||||
|
text = "".join(f"data: {json.dumps(f)}\n\n" for f in frames) + (
|
||||||
|
"data: [DONE]\n\n" if done else ""
|
||||||
|
)
|
||||||
|
return httpx.Response(200, content=text.encode(), headers={"content-type": "text/event-stream"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("usage", [{"prompt_tokens": 11, "completion_tokens": 7}, None])
|
||||||
|
async def test_salvage_override_stays_in_domain(usage):
|
||||||
|
"""打捞覆盖(`openai_compat._complete_stream`)是第三个字面量产出方。"""
|
||||||
|
frames = [{"choices": [{"delta": {"content": "partial"}}]}]
|
||||||
|
if usage is not None:
|
||||||
|
frames.append({"choices": [], "usage": usage})
|
||||||
|
transport = OpenAICompatTransport(
|
||||||
|
client_factory=lambda src: httpx.AsyncClient(
|
||||||
|
base_url=src.base_url,
|
||||||
|
transport=httpx.MockTransport(lambda request: _sse(*frames, done=False)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source = SourceConfig(
|
||||||
|
name="s1",
|
||||||
|
provider="qwen",
|
||||||
|
base_url="https://gw.example/v1",
|
||||||
|
api_key="sk",
|
||||||
|
model="m",
|
||||||
|
timeout_s=10.0,
|
||||||
|
est_tokens=4000,
|
||||||
|
missing_done="salvage",
|
||||||
|
)
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
source=source,
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="cid",
|
||||||
|
)
|
||||||
|
assert result.usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedOcrTransport:
|
||||||
|
async def recognize_text(self, *, image, source, call_id):
|
||||||
|
return OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
|
||||||
|
|
||||||
|
async def parse_layout(self, *, image, source, call_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def check_health(self, *, source):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ocr_emit_stays_in_domain():
|
||||||
|
"""`OcrClient._emit` 的字面量(ocr.py:411)同样纳入封闭性断言。
|
||||||
|
|
||||||
|
值取 `measured` 是设计 §3.3 的裁决(OCR 的 0 token 属事实);此处只断言
|
||||||
|
落在三态内,精确取值的防回归钉在 `test_ocr_client.py`。
|
||||||
|
"""
|
||||||
|
source = SourceConfig(
|
||||||
|
name="m1",
|
||||||
|
provider="monkey",
|
||||||
|
base_url="http://gw.example",
|
||||||
|
api_key="none",
|
||||||
|
model="monkey-ocr",
|
||||||
|
timeout_s=10.0,
|
||||||
|
)
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client = OcrClient(
|
||||||
|
scope="ocr",
|
||||||
|
sources=[source],
|
||||||
|
selector=RoundRobinSelector(),
|
||||||
|
limiter=InMemoryLimiter(
|
||||||
|
scope="ocr",
|
||||||
|
sources={source.name: source},
|
||||||
|
global_limits=GlobalLimits(max_concurrency=0, rpm=0, tpm=0),
|
||||||
|
lease_ttl_s=100.0,
|
||||||
|
),
|
||||||
|
breaker=InMemoryGate(
|
||||||
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
||||||
|
),
|
||||||
|
transport=_ScriptedOcrTransport(),
|
||||||
|
retry=RetryPolicy(max_attempts=1, backoff_base_s=0.001, backoff_max_s=0.01),
|
||||||
|
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.001),
|
||||||
|
telemetry=recorder,
|
||||||
|
)
|
||||||
|
await client.recognize_text(b"jpg")
|
||||||
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_client():
|
||||||
|
"""构造仅用于调用 `_merge` 的最小 EmbeddingClient(不发起任何调用)。"""
|
||||||
|
source = _src()
|
||||||
|
return EmbeddingClient(
|
||||||
|
scope="embed",
|
||||||
|
sources=[source],
|
||||||
|
selector=RoundRobinSelector(),
|
||||||
|
limiter=InMemoryLimiter(
|
||||||
|
scope="embed",
|
||||||
|
sources={source.name: source},
|
||||||
|
global_limits=GlobalLimits(max_concurrency=0, rpm=0, tpm=0),
|
||||||
|
lease_ttl_s=100.0,
|
||||||
|
),
|
||||||
|
breaker=InMemoryGate(
|
||||||
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
||||||
|
),
|
||||||
|
transport=object(),
|
||||||
|
retry=RetryPolicy(max_attempts=1, backoff_base_s=0.001, backoff_max_s=0.01),
|
||||||
|
backpressure=BackpressurePolicy(stall_window_s=300.0, poll_interval_s=0.001),
|
||||||
|
batch_size=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("first", "second"), list(itertools.product(_DOMAIN, repeat=2)))
|
||||||
|
def test_merge_stays_in_domain(first, second):
|
||||||
|
"""任意两批 usage_source 组合(含尚无生产者的 unavailable)合并后仍在三态内。"""
|
||||||
|
source = _src()
|
||||||
|
outcomes = [
|
||||||
|
_BatchOutcome(
|
||||||
|
result=EmbeddingTransportResult(
|
||||||
|
vectors=[[1.0]], dim=1, prompt_tokens=1, usage_source=value, raw={}
|
||||||
|
),
|
||||||
|
source=source,
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
)
|
||||||
|
for value in (first, second)
|
||||||
|
]
|
||||||
|
assert _merge_client()._merge(outcomes).usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_input_short_circuit_stays_in_domain():
|
||||||
|
"""空输入短路自造响应(embedding.py:151),不经 transport 也须落在三态内。"""
|
||||||
|
resp = await _merge_client().embed([])
|
||||||
|
assert resp.usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(usage_source):
|
||||||
|
return LLMResponse(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
model="m",
|
||||||
|
provider="p",
|
||||||
|
prompt_tokens=1,
|
||||||
|
completion_tokens=2,
|
||||||
|
latency_ms=30,
|
||||||
|
ttft_ms=None,
|
||||||
|
max_inter_token_ms=None,
|
||||||
|
cache_hit=False,
|
||||||
|
call_id="cid",
|
||||||
|
source_name="s1",
|
||||||
|
usage_source=usage_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||||
|
async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(recorder).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_src(),
|
||||||
|
call_id="cid",
|
||||||
|
latency_ms=10,
|
||||||
|
response=_resp(emitted),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||||
|
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(recorder).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_src(),
|
||||||
|
call_id="cid",
|
||||||
|
latency_ms=10,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
)
|
||||||
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||||
|
async def test_emit_cache_hit_stays_in_domain(emitted):
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(recorder).emit_cache_hit(request=_REQ, response=_resp(emitted))
|
||||||
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
async def test_emit_terminal_failure_stays_in_domain():
|
||||||
|
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(recorder).emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
|
||||||
|
)
|
||||||
|
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||||
Reference in New Issue
Block a user