Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7adcfff0fa | |||
| 5eb01a0096 | |||
| 48805cb9fb | |||
| 4c135075b3 | |||
| 82f4ec4910 | |||
| 89ff916bc8 | |||
| e5871cccd2 | |||
| 781579bf36 | |||
| acc419a29b | |||
| de7273598e | |||
| ce630a37ef | |||
| dfda59fec2 | |||
| 15b9b02e96 | |||
| cce7562d07 | |||
| 2958dc8231 | |||
| a5ebf72f17 | |||
| 4516761dbe | |||
| b6e4cc3f3b | |||
| c31cc1adad | |||
| 6bb64ca938 | |||
| 152fa264ed | |||
| 6023d11bfb | |||
| b12bf6ce79 | |||
| b24e224beb | |||
| 09e77f11f8 | |||
| 0cc89fb03c | |||
| 20fd899d93 | |||
| 486809b08b | |||
| 58cb55b869 | |||
| 86fb4d5536 | |||
| 32d7869043 | |||
| 966d548245 | |||
| c2fcd5b1f8 | |||
| 0ed9dc107c | |||
| c4eda119ac | |||
| cd1a9520ff | |||
| 0aa7202c87 | |||
| 4841d901af | |||
| 30d7ffd94a | |||
| 037e7a011e | |||
| 0e2f734b0b | |||
| 7ccb25e8f1 | |||
| 42d16919fc | |||
| 005a90ca19 | |||
| 8a824e2000 |
@@ -18,6 +18,10 @@ LLM__QWEN__1__TIMEOUT_S=120
|
|||||||
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
# LLM__QWEN__1__ENABLE_THINKING=true # 三态: 缺省=不注入 / true=注入开启 / false=注入关闭
|
||||||
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
||||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||||
|
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||||
|
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
||||||
|
# 禁用键 model/messages/stream/stream_options(会击穿治理),配了直接报错
|
||||||
|
# OCR/EMBED scope 不消费该键: 配了会被忽略并 warning(见 issue #4 决策 G)
|
||||||
|
|
||||||
# ══ scope 级全局闸(跨源合计;0/缺省 = 不启用)══
|
# ══ scope 级全局闸(跨源合计;0/缺省 = 不启用)══
|
||||||
# LLM__GLOBAL__MAX_CONCURRENCY=8
|
# LLM__GLOBAL__MAX_CONCURRENCY=8
|
||||||
@@ -54,6 +58,8 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
||||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||||
|
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||||
|
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||||
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
# PGW_CACHE_NAMESPACE=<项目名或租户前缀> # 缓存启用时必填(防跨项目毒化)
|
||||||
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
# PGW_CACHE_TTL_S=604800 # 缓存启用时必填,须 > 0
|
||||||
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
# PGW_STRUCTURED_MAX_RETRIES=2 # 缺省 2(M2.5);0 = 解析失败不重问(CHS 策略)
|
||||||
|
|||||||
@@ -1,5 +1,69 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.6(2026-08-02)
|
||||||
|
|
||||||
|
推理开关能力建模与 `reasoning_tokens` 采集。`enable_thinking=False` 此前对 `minimax` / `openai` 两类源**完全不产生效果**——两个 profile 的 thinking 两档皆为空字典,`payload.update({})` 是空操作,而配置方以为关掉了推理。这比"不提供这个开关"更危险:不提供的话调用方会去找别的办法,提供了但静默失效,调用方就带着一个错误的前提往下走。一个下游项目正卡在这上面。
|
||||||
|
|
||||||
|
### 行为变更(**请先读这一条**)
|
||||||
|
|
||||||
|
- **MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效"。** 经实测,MiniMax 认的开关是 `reasoning_effort` 而非 `enable_thinking` / `thinking`(后两者被静默丢弃);现在 `False` 注入 `reasoning_effort: none`、`True` 注入 `medium`。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。
|
||||||
|
- **`MiniMax-M2.7` / `MiniMax-M2.5` 配 `ENABLE_THINKING=false` 会在装配期报错。** 这两个模型的推理**关不掉**,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。
|
||||||
|
- **`provider=openai` 的源配任何非 `None` 的 `ENABLE_THINKING` 会在装配期报错。** 该段名实践中被复用为任意 OpenAI 兼容厂商的兜底,向未知厂商下发厂商方言参数会 400。要控制推理请 `register_provider` 注册形态,或用 `SourceConfig.extra_body` 直接下发。
|
||||||
|
- **`enable_thinking` 进入缓存指纹。** 它现在真的改变请求体,不进指纹就会出现"关掉推理后重启读到开着推理时的旧响应"。**配了该项的 scope 会有一次性冷启动**;未配的 scope 指纹字面量逐字不变,不受影响。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **`LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`**(issue #6)。推理 token 已计入 `completion_tokens`,故**成本总额一直是对的**——这不是计费缺口,是归因缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分。
|
||||||
|
- **遥测表 `llm_calls` 新增 `reasoning_tokens` 列**,`TelemetryRecorder` 端口由 21 字段扩为 22;补列纪律与 issue #3/#4 逐字相同(排末尾、先探测再 ALTER、失败只逐行降级)。
|
||||||
|
- **`ProviderProfile` 的 thinking 两档类型放宽为 `Mapping | None`**,三值语义互不重叠:`{...}` 已知注入片段 / `{}` 已知无需注入 / `None` **未知**。空字典曾同时承载后两种含义,那正是本次 bug 的根因。
|
||||||
|
- **新增 model 级能力表** `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability`,以及单一判定函数 `resolve_thinking`。形态(参数长什么样)按 provider 变、数年不变一次;能力(能否关闭)按 model 变、每代都变——provider 级的表在物理上表达不了同厂代际差异。每条登记都附实测证据与日期。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **`reasoning_tokens` 的 `None` 是"本次调用未上报",不是"该源不上报"**,与 `cached_prompt_tokens` 的 NULL 语义**不同**。中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故判据须写 `in (None, 0)`;**写 `== 0` 的条件永远不成立**——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。
|
||||||
|
- **不要用输出长度反推是否发生了推理。** 两档的 `completion_tokens` 分布是重叠的(实测关闭档最高 46、开启档最低 13),按阈值判两个方向都会误判。唯一可靠的判别量是 `reasoning_tokens`。
|
||||||
|
- **`enable_thinking=True` 对 MiniMax 映射到 `medium` 档。** 它是五档旋钮而库给的是布尔开关,这个映射是库做的选择:`medium` 对应"厂商正常强度",与 qwen 的 `enable_thinking:true`、deepseek 的 `thinking:{enabled}` 同为"不指定预算、由模型自定"的语义。要精确控制档位用 `extra_body={"reasoning_effort": "..."}`,它的优先级高于 profile 注入。
|
||||||
|
- **未登记的模型不会被挡住**,按 provider 形态尽力注入并发一条 warning。新模型上线不该被库拦下,但也不该假装成功;实测后请用 `register_capability` 登记。
|
||||||
|
- **`pricing.py` 一行未改。** 推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||||
|
|
||||||
|
## 1.0.5(2026-07-31)
|
||||||
|
|
||||||
|
采样参数透传(issue #4)。`chat()` 此前没有任何途径设置 `temperature` / `seed` / `max_tokens`——全库检索 `temperature` 零命中,`ChatRequest.overlay` 虽会被并进请求体却只由结构化中间件填充,调用方够不着。对受控实验而言这是阻塞性的:解码温度未知且可能随供应商默认值变化,每格配置跑 5 个 seed 报出的标准差无从解释。
|
||||||
|
|
||||||
|
### 新增(纯增,不破坏任何现有调用方)
|
||||||
|
|
||||||
|
- **`chat()` 新增 keyword-only 参数 `overlay: Mapping[str, Any] | None = None`**,承载逐次变化的采样参数(每个 rollout 不同的 `seed`)。带默认值的 keyword-only 参数不改变既有调用点。
|
||||||
|
- **`SourceConfig` 新增 `extra_body` 字段**,对应环境键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(JSON **对象**串),承载全局恒定的参数(`temperature=0`)——免得每个调用点都要记得传,而漏传一次不会报错、只会让数字悄悄不可比。
|
||||||
|
- **优先级为 结构化注入 > 调用级 `overlay` > 源级 `extra_body`。** 由现有层序天然给出,未引入新机制。
|
||||||
|
- **遥测表 `llm_calls` 新增 `sampling` 列**,`TelemetryRecorder` 端口由 20 字段扩为 21;补列走 1.0.4 已建立的"先探测缺列再 ALTER、失败只逐行降级"套路。列语义是「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,**不含**结构化输出注入的 `response_format`(列名是采样参数,而数 KB 的 schema 逐行落库只会让审计表膨胀)。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **采样参数进缓存 key,所以逐次变化的 `seed` 天然全部 miss。** 这是正确语义而非缺陷:不进 key 的话,同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错。代价是缓存对这条路径不再省钱。**不传采样参数时 key 逐字不变**,存量缓存不受影响。
|
||||||
|
- **`model_fingerprint` 是集合级指纹,不是本次选中源的指纹。** 同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应(这是既有取舍的延续,`model` 一直如此)。要求逐源可复现的实验应让每个源独享 scope 或 namespace。
|
||||||
|
- **`{model, messages, stream, stream_options}` 是保护键,配了直接报 `ValueError`。** 它们由治理层拥有:`model` 被覆盖会让成本按错单价算,`stream`/`stream_options` 会绕过流式看门狗、丢掉 usage 帧。不可 JSON 序列化的值(如 numpy 标量)同样在进洋葱之前报错——否则会在缓存层的降级保护之外抛裸 `TypeError`,连一行遥测都留不下。
|
||||||
|
- **`SourceConfig` 不再 hashable**,`dataclasses.asdict()` / `copy.deepcopy()` 也不再适用(加任何 mapping 字段的固有代价,裸 dict 亦然)。要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`。
|
||||||
|
- **OCR / embedding 路径不消费 `extra_body`**:配了会被**剥离并 warning**,装配照常成功。这两条路径的 transport 根本不发这个值(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart 表单),剥离是为了让遥测不至于记录一个从未发出的参数。需要 `dimensions` 等 embedding 参数请提 issue。
|
||||||
|
- **`enable_thinking` 对 `openai` / `minimax` 两个 provider 不产生任何效果**(它们的 thinking profile 两档皆空)。此前没有任何地方说明这一点,调用方可能以为自己关掉了推理。需要下发自定义参数请用 `extra_body`。
|
||||||
|
|
||||||
|
## 1.0.4(2026-07-31)
|
||||||
|
|
||||||
|
响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。
|
||||||
|
|
||||||
|
### 新增(纯增字段,不破坏任何现有调用方)
|
||||||
|
|
||||||
|
- **`LLMResponse` 新增 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`。** 前者是供应商 prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`),后者是 API 响应体里的 `model` 字段(与 `.env` 配的别名可能分叉——供应商把别名指向新权重时,只有它认得出真正跑的那个版本)。两者均带默认值 `None`,逐字段传参的 fake 构造零改动。
|
||||||
|
- **`None` 与 `0` 是两回事,不可混同。** `None` = 该源不上报这个数(下游据此声明「本源不可做缓存成本校正」);`0` = 该源上报了一次真实零命中。网关报文一律不可信:形态异常(负数、字符串、`bool`、`prompt_tokens_details` 非 dict)一律归 `None` 且绝不抛异常——可观测字段缺失不得打断调用。
|
||||||
|
- **遥测表 `llm_calls` 新增 `cached_prompt_tokens` 与 `model_reported` 两列**,`TelemetryRecorder` 端口由 18 字段扩为 20。两个后端在初始化期对**已存在的旧表幂等补列**——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃、遥测静默全失。两侧都是**先探测缺列、只在真缺列时才 ALTER**(SQLite 查 `PRAGMA table_info`,Postgres 查 `pg_attribute`):`ADD COLUMN IF NOT EXISTS` 即使列已存在也会先取 ACCESS EXCLUSIVE 锁,而遥测是内联 await,让每个进程的首次写入都去锁共享审计表会拖垮业务调用;稳态下一条 ALTER 都不会发。**补列失败只降级为逐行丢弃,绝不会让 recorder 整体失能**(应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership 检查早于存在性判断,列齐全也会失败)。
|
||||||
|
- **`PricingTable` 支持可选的缓存读取单价 `cached_input_per_1m`。** 配了该档且本次有命中时按 `(prompt - cached) × input + cached × cached_input` 分段计价,消除 cost 的系统性高估;**未配则不猜折扣率**,退化为现状全额输入价(P5 严禁默认值掩盖)。旧价格表文件与 embedding 侧的三参 `cost()` 调用零改动。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||||
|
|
||||||
|
### 下游请读
|
||||||
|
|
||||||
|
- **`cache_hit` 与新字段是两个不同的东西。** `cache_hit` 指的始终是 **PolyGateway 自身的响应缓存**(未产生网关调用),而 `cached_prompt_tokens` 指的是**供应商服务器**复用了提示词前缀、那部分按更低单价计费——真实调用里天天发生,`cache_hit` 永远看不见它。字段名保持不变(改名会破坏迁移兼容),语义已在 docstring 中消歧。
|
||||||
|
- **统计供应商缓存命中率必须写 `WHERE cache_hit = false`。** 缓存命中行的这两个字段是**原样回放**的历史值(与 `model`、`prompt_tokens` 同一口径:`CacheMW` 只覆写与本次调用相关的时序字段),计入会重复计数。这与 1.0.3 里 `cost` 缺口口径的坑是同一类。
|
||||||
|
- 缓存命中行的 `cost` 仍恒为 `0.0`(未产生新调用),该短路排在任何单价换算之前,不受缓存单价档影响。
|
||||||
|
- 旧格式的缓存条目(缺这两个键)照常可重建为 `None`,不会回源;历史遥测行的新列为 NULL。
|
||||||
|
|
||||||
## 1.0.3(2026-07-30)
|
## 1.0.3(2026-07-30)
|
||||||
|
|
||||||
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
`est_tokens` 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install test lint format check ci wiki
|
.PHONY: install test lint format check ci wiki wiki-check
|
||||||
|
|
||||||
ENV := PolyGateway
|
ENV := PolyGateway
|
||||||
|
|
||||||
@@ -24,3 +24,10 @@ ci: check test
|
|||||||
|
|
||||||
wiki:
|
wiki:
|
||||||
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||||
|
|
||||||
|
# 用户文档站(Gitea Wiki)与源码的机械对齐校验。wiki 是独立仓库,须显式给路径:
|
||||||
|
# make wiki-check WIKI=~/PolyGateway.wiki
|
||||||
|
# 不并入 ci: 仓库里没有 wiki,自动跳过等于静默降级(违 P5),宁可让人显式跑。
|
||||||
|
wiki-check:
|
||||||
|
@test -n "$(WIKI)" || (echo "用法: make wiki-check WIKI=<PolyGateway.wiki 克隆路径>" && exit 1)
|
||||||
|
conda run -n $(ENV) python3 tools/check_wiki_alignment.py --wiki $(WIKI)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async def main() -> None:
|
|||||||
await client.aclose() # 归还连接与治理后端资源
|
await client.aclose() # 归还连接与治理后端资源
|
||||||
```
|
```
|
||||||
|
|
||||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制。
|
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||||
|
|
||||||
### 3. OCR 与 Embedding
|
### 3. OCR 与 Embedding
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"input_per_1m": 2.1,
|
||||||
|
"output_per_1m": 8.4
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.0.3"
|
version = "1.0.6"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -328,7 +328,18 @@ flowchart TB
|
|||||||
| `cache_hit` | bool | 是否缓存命中 |
|
| `cache_hit` | bool | 是否缓存命中 |
|
||||||
| `call_id` | str | UUID,每次**尝试**独立 |
|
| `call_id` | str | UUID,每次**尝试**独立 |
|
||||||
|
|
||||||
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)。
|
新增字段(库扩展,全部带默认值): `source_name`(多源溯源)、`cost`(pricing 换算,可为 None)、`usage_source`(三态,见下)、`structured_data`(D14 阶梯通过后的解析产物;不参与缓存序列化,命中时由 CacheMW 复用 strategy 零网络重建)、`cached_prompt_tokens` 与 `model_reported`(2026-07-31,issue #3,见下)。
|
||||||
|
|
||||||
|
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
|
||||||
|
|
||||||
|
| 字段 | 含义 | 生产者 |
|
||||||
|
|---|---|---|
|
||||||
|
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
|
||||||
|
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
|
||||||
|
|
||||||
|
`cache_hit` 指的始终是 **PolyGateway 自身响应缓存**,与供应商 prompt cache 无关;两者语义不同但名字相近,docstring 已消歧(改名会破坏迁移兼容,故只注释)。
|
||||||
|
|
||||||
|
**缓存命中行的口径(决策 B1)**: 与 `model`/`prompt_tokens` 同一规则——`CacheMW._rehydrate` 只覆写与本次调用相关的时序字段,这两个新字段**原样回放**历史值。故**统计供应商缓存命中率必须写 `WHERE cache_hit = false`**,否则回放行会被重复计数(与 §5.1 `cost` 缺口口径同款教训)。
|
||||||
|
|
||||||
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
|
||||||
|
|
||||||
@@ -350,6 +361,8 @@ flowchart TB
|
|||||||
|
|
||||||
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
|
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
|
||||||
|
|
||||||
|
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 错误模型
|
## 6. 错误模型
|
||||||
@@ -424,11 +437,13 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.5 响应缓存
|
### 7.5 响应缓存
|
||||||
|
|
||||||
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt}))`,前缀 `pgw:cache:`。
|
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`。
|
||||||
|
|
||||||
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
|
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
|
||||||
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
|
- `namespace`: 必填(项目名/租户 id),修正 GovDoc 缓存 key 缺租户隔离与多项目共用 Redis 时的互相毒化风险。
|
||||||
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
|
- `salt`: 可选,跨 epoch 强制重采样(Video-Tree 需求)。
|
||||||
|
- `sampling`(2026-07-31,issue #4): 调用级采样参数,**仅非空时参与**(注意与 `salt` 的"仅非 None"不同——空串是有意义的 salt,而空采样参数与不传无差别),故空 overlay 时旧键逐字不变、存量缓存不冷启动。读 `request.sampling` 而非 `request.overlay`,不依赖"CacheMW 恰在 StructuredMW 外侧"的层序巧合。**不进 key 的后果**: 同 messages 跑 5 个 seed 会全部命中第一次的响应,标准差恒为 0 且不报错——受控实验静默作废。源级 `extra_body` 同理并入 `model_fingerprint`(全源皆空时字面量不变,否则追加 `|sha256(...)`,摘要对象是各源 `(model, extra_body)` 的 canonical JSON 排序去重——按模型而非源名,改源名不误触冷启动)。
|
||||||
|
- **两条已知副作用**: ① 逐 rollout 变化的 `seed` 进 key 后该路径天然全部 miss(正确语义,但缓存对它不再省钱);② `model_fingerprint` 是**集合级**指纹而非本次选中源的指纹,同 scope 各源 `extra_body` 不同时仍可能返回另一源的响应(既有取舍的延续,与 `model` 同),要求逐源可复现应让每源独享 scope 或 namespace。
|
||||||
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
|
- value = `LLMResponse` 的 JSON;TTL 必填且 > 0(禁止永不过期,继承 Video-Tree 校验);Redis 不可用 → get 返回 None、set 吞异常记 warning(静默降级)。**只缓存成功响应**;`ResultInvalidError` 的原始响应不缓存(避免固化坏结果)。
|
||||||
|
|
||||||
### 7.6 流式活性看门狗
|
### 7.6 流式活性看门狗
|
||||||
@@ -437,7 +452,7 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.7 多源与选源
|
### 7.7 多源与选源
|
||||||
|
|
||||||
`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)。
|
`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/`extra_body`(2026-07-31 issue #4: 本源恒定的采样参数,构造期校验保护键后转 `MappingProxyType`;**该字段令 SourceConfig 不再 hashable**——加任何 mapping 字段的固有代价,库内无以源作 dict key/set 元素的写法,要可变副本用 `dict(...)`、要改字段用 `dataclasses.replace`)。聚合自环境变量 `{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 有效预扣量(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。
|
||||||
|
|
||||||
@@ -449,11 +464,15 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.8 遥测与成本
|
### 7.8 遥测与成本
|
||||||
|
|
||||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。
|
||||||
|
|
||||||
|
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||||
|
|
||||||
|
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||||
|
|
||||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||||
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
||||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。
|
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||||
|
|
||||||
### 7.9 结构化输出阶梯(D14)
|
### 7.9 结构化输出阶梯(D14)
|
||||||
|
|
||||||
@@ -509,6 +528,7 @@ src/polygateway/
|
|||||||
|
|
||||||
- **载体**: `.env` + 环境变量(工程配置);缺失关键配置直接报错,严禁硬编码默认值兜底(三项目共同铁律)。**实现勘误(2026-07-20 M1,人类确认)**: 多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 是动态键族,pydantic-settings 的静态字段模型无法表达,故 `GatewaySettings` 为 frozen dataclass + python-dotenv(显式核心依赖)读取,fail-loud 校验语义与 pydantic-settings 一致。
|
- **载体**: `.env` + 环境变量(工程配置);缺失关键配置直接报错,严禁硬编码默认值兜底(三项目共同铁律)。**实现勘误(2026-07-20 M1,人类确认)**: 多源 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 是动态键族,pydantic-settings 的静态字段模型无法表达,故 `GatewaySettings` 为 frozen dataclass + python-dotenv(显式核心依赖)读取,fail-loud 校验语义与 pydantic-settings 一致。
|
||||||
- **多源命名**: `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(如 `LLM__QWEN__1__API_KEY`、`OCR__MONKEY__1__BASE_URL`),聚合为 `list[SourceConfig]`;SCOPE 支持逻辑角色前缀(§7.7)。
|
- **多源命名**: `{SCOPE}__{PROVIDER}__{N}__{FIELD}`(如 `LLM__QWEN__1__API_KEY`、`OCR__MONKEY__1__BASE_URL`),聚合为 `list[SourceConfig]`;SCOPE 支持逻辑角色前缀(§7.7)。
|
||||||
|
- **`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`(2026-07-31,issue #4)**: 值为 JSON **对象**串(数组/标量报错),解析为源级恒定采样参数。`_SOURCE_FIELDS` 是跨 scope 共用的一张表,故该键在 `OCR__`/`EMBED__` 下也语法合法,但那两条路径不消费它(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart)——处置为**构造期剥离 + warning 放行**而非报错(2026-07-31 人类拍板: 这两条路径本无采样语义,配错后果远轻于 chat,不值得让下游装配起不来)。剥离本身是承重的: 不剥离则遥测 `sampling` 列会记录一个从未发出的参数(§7.8),那是数据造假而非参数失效。
|
||||||
- **韧性参数键名**沿用三项目习惯(`LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`),降低三项目迁移改名成本。
|
- **韧性参数键名**沿用三项目习惯(`LLM_TIMEOUT` / `LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`),降低三项目迁移改名成本。
|
||||||
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
|
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
|
||||||
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
|
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 响应可观测字段扩展设计(Issue #3)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-31
|
||||||
|
- **来源**: Gitea Issue #3(下游 dissect 审计需求)
|
||||||
|
- **状态**: 已批准(2026-07-31,人类逐条确认 A2 / B1 / C1 / D1)
|
||||||
|
- **触发档位**: 强制(变更 `types.py` 公共类型 + `ports.py` 端口签名 + 遥测持久化 schema)
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 目标 1 | `LLMResponse` 暴露供应商侧 prompt cache 命中的输入 token 数 |
|
||||||
|
| 目标 2 | `LLMResponse` 暴露 API 响应体实际返回的模型版本串 |
|
||||||
|
| 目标 3 | 两字段同步落 `llm_calls` 遥测表(端口 18 → 20 字段) |
|
||||||
|
| 目标 4 | `PricingTable` 支持可选的缓存读取单价,消除 cost 高估 |
|
||||||
|
| 非目标 1 | 不改 `EmbeddingResponse` / OCR 响应——embedding 与 OCR 无 prompt cache 语义,且 issue 未提;`cost()` 新增参数带默认值,embedding 调用点(`embedding.py:419`)零改动 |
|
||||||
|
| 非目标 2 | 不改 `cache_hit` 字段名/类型(破兼容),只在 docstring 消歧 |
|
||||||
|
| 非目标 3 | 不为 `reasoning_tokens` 等其他 usage 细项开口(YAGNI,无下游需求) |
|
||||||
|
|
||||||
|
### 1.1 Issue 前提的一处修正
|
||||||
|
|
||||||
|
Issue 称「两者的数据都已经存在于 `TransportResult.raw` 里」。核查结果:
|
||||||
|
|
||||||
|
| 数据 | 实际所在 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| `usage.prompt_tokens_details.cached_tokens` | `raw={"usage": ...}`(流式 `openai_compat.py:362`、非流式 `:444`) | ✅ 已在 raw 内 |
|
||||||
|
| 响应体顶层 `model` | **不在**。非流式 raw 只放 `body["usage"]`;流式 sink 只吸收 `usage` 与 `done` 两键(`_sse_delta`,`:44-47`),chunk 的 `model` 从未收集 | ❌ 需改 transport 采集 |
|
||||||
|
|
||||||
|
故本变更**不是纯字段暴露**,必须同时改 `transports/`。这决定了下面决策 A 的必要性。
|
||||||
|
|
||||||
|
## 2. 决策 A:字段的采集与传递路径
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 raw 约定键 | transport 往 `raw` 里塞 `{"model": ...}`;RetryMW 读 `raw.get("model")` 与 `raw["usage"]["prompt_tokens_details"]["cached_tokens"]` | 改动最小;但 `raw: dict[str, Any]` 变成隐式契约,键名靠约定;且 middleware 要懂 OpenAI 报文嵌套结构 |
|
||||||
|
| A2 TransportResult 强类型字段(**推荐**) | `TransportResult` 追加 `cached_prompt_tokens: int \| None = None`、`model_reported: str \| None = None`;解析逻辑留在 `openai_compat.py`;RetryMW 直接搬运 | 报文格式知识不出 `transports/`,middleware 只做搬运,符合 P7(middleware 只依赖端口、不懂具体报文);两字段带默认值,`monkey_ocr` 的 OCR 结果类型不受影响 |
|
||||||
|
| A3 middleware 解析 raw | RetryMW 内写 OpenAI 嵌套路径解析 | 把 provider 报文格式知识放进 middleware 层,新增非 OpenAI 兼容 transport 时会分叉;违反分层,否决 |
|
||||||
|
|
||||||
|
**选 A2**。`TransportResult` 是库内部流转类型(非三项目消费面),但仍按「新增必带默认值」处理,使 `openai_compat` 之外的构造点零改动;全库该类型仅 2 处构造(`openai_compat.py:354/436`)。
|
||||||
|
|
||||||
|
解析纪律(P5 一切外部输入校验后使用):`cached_tokens` 与 `model` 均来自网关响应,类型不可信。取值走防御 helper,不抛异常(可观测字段缺失绝不能打断主路径):
|
||||||
|
|
||||||
|
| 输入 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `cached_tokens` 为非负 `int`(**含 `0`**) | 如实保留——`0` 是「该源上报了一次真实零命中」,与「未上报」的 `None` 语义不同,这正是本 issue 的核心诉求 |
|
||||||
|
| `cached_tokens` 为负数 / 非 `int` / `bool` | `None`(`bool` 必须显式排除:`isinstance(True, int)` 在 Python 里为真) |
|
||||||
|
| `usage` 或 `prompt_tokens_details` 非 dict | `None` |
|
||||||
|
| `model` 为非空 `str` | 保留 |
|
||||||
|
| `model` 为非 `str` / 空白串 | `None` |
|
||||||
|
|
||||||
|
## 3. 决策 B:缓存命中回放时两字段取什么值
|
||||||
|
|
||||||
|
| 方案 | LLMResponse 层 | 遥测层 | 权衡 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 原样回放(**推荐**) | 随缓存 JSON 回放原值 | 照记回放值 | 与既有口径一致——`CacheMW._rehydrate`(`cache.py:113-119`)只覆写与本次调用相关的时序字段(`latency_ms`/`ttft_ms`/`max_inter_token_ms`/`call_id`/`cache_hit`),`model`/`provider`/`prompt_tokens` 全部回放。新字段与它们同类(溯源 + 用量),按同一规则处理 |
|
||||||
|
| B2 命中时置 None | 覆写为 None | NULL | 语义上「本次未打供应商,无供应商侧事实」也成立,但与同层的 `prompt_tokens` 回放行为不一致,下游要记两套规则 |
|
||||||
|
| B3 混合 | `model_reported` 回放、`cached_prompt_tokens` 置 None | 同左 | 最难解释,否决 |
|
||||||
|
|
||||||
|
**选 B1**,并写入文档一条度量口径约束(与 `cost` 缺口口径同款教训,ARCHITECTURE §5.1):
|
||||||
|
|
||||||
|
> 统计供应商缓存命中率必须写 `WHERE cache_hit = false`——缓存命中行的 `cached_prompt_tokens` 是历史回放值,计入会重复计数。
|
||||||
|
|
||||||
|
`cost` 不受影响:遥测层 `cache_hit=True` 分支仍短路为 `0.0`,早于任何单价换算。
|
||||||
|
|
||||||
|
## 4. 决策 C:缓存读取单价(人类已选「增加可选档」)
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| C1 ModelPrice 可选第三档(**推荐**) | `cached_input_per_1m: float \| None = None`;`cost()` 增可选参 `cached_prompt_tokens: int \| None = None` | 价格表旧文件零改动仍可加载;`embedding.py:419` 的三参调用零改动 |
|
||||||
|
| C2 cost() 收 LLMResponse | 换算函数直接吃响应对象 | `pricing.py` 会反向依赖 `types.py` 且难以单测纯函数,否决 |
|
||||||
|
|
||||||
|
换算规则与退化路径:
|
||||||
|
|
||||||
|
| 条件 | 计价方式 |
|
||||||
|
|---|---|
|
||||||
|
| 配了 `cached_input_per_1m` 且本次 `cached_prompt_tokens` 为正 | `(prompt - cached) × input + cached × cached_input` |
|
||||||
|
| 未配该档,或本次 `cached_prompt_tokens` 为 None/0 | 全额按 `input` 计(现状行为,不变) |
|
||||||
|
| `cached > prompt`(网关口径异常) | 按 `cached = prompt` 夹取并记一次 warning;不抛异常、不产生负成本 |
|
||||||
|
|
||||||
|
**不猜折扣率**:未配置缓存档时绝不按「五分之一」之类经验值折算(P5 严禁默认值掩盖)。`from_file` 的 fail-loud 校验对新档同样适用:出现该键但非数或为负 → `ValueError`。
|
||||||
|
|
||||||
|
## 5. 决策 D:遥测表扩列的落地方式
|
||||||
|
|
||||||
|
人类确认「现在不存在必须保留的生产库」。但两个后端的 DDL 都是 `CREATE TABLE IF NOT EXISTS`,**已存在的开发库/下游库不会自动获得新列**,INSERT 会失败。两侧的失败形态都是**逐行 warning 丢弃**(SQLite `sqlite.py:93`;PG `postgres.py:127`——`_failed` 结构性标志只在 `_ensure_ready` 建池/建表失败时置位,与写入路径无关),即每一次调用的遥测行都丢,却不会有任何一次硬失败提示,与「遥测必录」相悖。
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 初始化期幂等补列(**推荐**) | DDL 加新列;初始化时按需 `ALTER TABLE ADD COLUMN`——PG 用原生 `IF NOT EXISTS`,SQLite 先查 `PRAGMA table_info` 再按需 ALTER | 旧库自动升列,新库无副作用;两处各约 5 行;补列失败沿用现有降级策略(warning,不冒泡) |
|
||||||
|
| D2 只改 DDL,文档写「删表重建」 | 零代码 | 已建表的开发机/下游踩坑后只看到降级 warning,排查成本高;违反防御性 |
|
||||||
|
| D3 引入迁移框架(alembic) | 正规版本化迁移 | 新增依赖,与「依赖极简」铁律冲突,规模严重不匹配,否决 |
|
||||||
|
|
||||||
|
**选 D1**。列类型:SQLite `cached_prompt_tokens INTEGER` / `model_reported TEXT`;PG `INTEGER` / `TEXT`。两列均可空(NULL = 该源未上报),不设 NOT NULL 与默认值——0 与 NULL 的区分正是本 issue 的核心诉求。
|
||||||
|
|
||||||
|
**D1 的实现纪律(必须钉进计划,否则补列会把降级放大成永久失能)**:
|
||||||
|
|
||||||
|
| 约束 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| SQLite 的 ALTER 必须用**独立 try**,且置于 `self._conn = conn` **之后** | `__init__` 现有 try 的最后一句才是 `self._conn = conn`(`sqlite.py:74-84`);ALTER 抛异常会让 `_conn` 停在 `None`,`record_llm_call` 首行即 return —— 整个 recorder 永久 no-op,比逐行丢弃严重得多 |
|
||||||
|
| `duplicate column name` 视为成功吞掉 | `PRAGMA table_info` 探测 + ALTER 是 TOCTOU:多 worker 共用同一 db 文件时后到者必然撞上 |
|
||||||
|
| 不得为补列加宽 `except` | `sqlite.py:93` 只捕 `(OSError, sqlite3.Error)`,取消是天然穿透的;PG 侧的 `except asyncio.CancelledError: raise` 必须留在最前 |
|
||||||
|
| PG 用原生 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 无 TOCTOU;落在既有 `_init_lock` 保护的 `_ensure_ready` 内 |
|
||||||
|
|
||||||
|
端口 `TelemetryRecorder.record_llm_call` 由 18 字段扩为 20 字段(关键字参数),`ports.py:248` 的「18 字段冻结」注释与 ARCHITECTURE 相应表述同步更新。新增参数在 Protocol 上**不设默认值**——依据不是「漏改会报错」(本仓无 mypy,`make lint` 只有 ruff + import-linter,8 个测试 fake 全是 `**fields`,漏改根本不会自动红),而是**库外不存在第三方实现者**:三项目迁移文档明确删除各自的 TelemetryRecorder Protocol 与实现(`migrations/govdoc-saas.md:36`、`video-tree-trm5.md:36/51`),端口的唯一实现者就是库内两个后端,完整签名的成本为零。漏改的兜底靠 §8 的键集合断言测试,不靠类型检查。
|
||||||
|
|
||||||
|
## 6. 行为审计(既有行为逐条标注)
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
|---|---|
|
||||||
|
| `LLMResponse` 前 11 字段顺序即公共承诺 | **保留**,新字段追加到尾部(`structured_data` 之后) |
|
||||||
|
| 缓存序列化 `_serialize` 用 `asdict` 后 pop 掉 `structured_data`、`_rehydrate` 按 `_RESPONSE_FIELDS` 过滤 | **保留**。新字段自动进出;旧缓存条目缺这两键时,`LLMResponse(**fields)` 靠默认值构造成功(向后兼容已验证) |
|
||||||
|
| `cache_hit` 语义 = PolyGateway 自身响应缓存 | **保留**,仅补 docstring 消歧 |
|
||||||
|
| `TelemetryEmitter` 单一 `_record` helper(遥测必录铁律:禁止复制参数列表) | **保留**,新字段只在 `_record` 增两个参数,三个 `emit_*` 入口各传一次 |
|
||||||
|
| 失败尝试 / 终态失败行记 `usage_source="unavailable"` | **保留**,两个新字段在这些路径记 `None` |
|
||||||
|
| `pricing.cost()` 是唯一换算点(注释语)| **修正**:实际有 `TelemetryEmitter` 与 `embedding.py:419` 两个调用点,顺带订正该 docstring(限于一行注释,不做结构重构) |
|
||||||
|
| OCR / embedding 各自构造 `LLMResponse` | **保留**,两字段取默认 `None`(该路径无供应商 cache 概念) |
|
||||||
|
|
||||||
|
## 7. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 并发与取消 | 纯数据字段,无新增 await 点、无共享状态。PG 补列在既有 `_init_lock` 保护的 `_ensure_ready` 内,并发首调用不会重复 ALTER;SQLite 的 `__init__` **不持** `_lock`(它只保护 `_write`/`close`),跨进程共库靠上面 D1 纪律里的「duplicate column 视为成功」兜底。取消穿透不变:PG 两处 `except asyncio.CancelledError: raise` 保持在最前,SQLite 侧只捕 `(OSError, sqlite3.Error)` 故天然穿透 |
|
||||||
|
| 降级方向 | 遥测属「静默降级」侧:补列失败 → warning 并沿用既有逐行丢弃,绝不冒泡到调用方,也绝不让 recorder 整体失能(见 D1 纪律)。解析失败 → 字段记 `None`,不影响响应返回 |
|
||||||
|
| 幂等与重复 | 补列幂等(PG `IF NOT EXISTS`;SQLite 先探测)。写入幂等性不变(`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING` 按 `call_id`) |
|
||||||
|
| 持久化与原子性 | 单行 INSERT 原子性不变;新增两列不参与主键与冲突判定。缓存 JSON 是整值覆写,无部分写入 |
|
||||||
|
| 向后兼容 | 下游三项目 + dissect:纯增字段带默认值,逐字段传参的 fake 构造零改动;旧价格表文件、旧缓存条目、旧遥测表均可继续工作 |
|
||||||
|
|
||||||
|
## 8. 错误处理与测试策略
|
||||||
|
|
||||||
|
错误分类:本变更**不新增任何错误路径**。网关报文里这两项缺失或类型异常 → 记 `None`,不归入四分类(它们不是失败,是「该源没给」)。价格表配置错误仍走装配期 `ValueError`(fail-loud,不属运行时四分类)。
|
||||||
|
|
||||||
|
| 层 | 测试(先失败后通过) |
|
||||||
|
|---|---|
|
||||||
|
| types(unit) | 新字段默认值为 `None`;字段顺序不变(前 11 位置构造仍成立) |
|
||||||
|
| transports(unit) | 用真实网关响应二次构造样本:① 流式含 `prompt_tokens_details.cached_tokens` → 解析出正整数;② 非流式同上;③ 无该键 → `None`;④ 值为 `"abc"`/负数 → `None` 不抛;⑤ 流式 chunk 的 `model` 被 sink 采集;⑥ 顶层无 `model` → `None` |
|
||||||
|
| retry(unit) | `_build_response` 透传两字段;失败尝试路径不受影响 |
|
||||||
|
| cache(unit) | ① 新字段随序列化往返;② **旧格式**缓存条目(缺这两键)仍能 rehydrate;③ 命中回放值符合 B1 |
|
||||||
|
| pricing(unit) | ① 配缓存档 + 命中 → 成本低于全额;② 未配该档 → 与现状逐位相等;③ `cached > prompt` → 夹取且不为负;④ 三参旧调用签名仍可用(embedding 调用形态);⑤ 价格表含负缓存单价 → `ValueError` |
|
||||||
|
| telemetry(integration) | ① 20 字段写入 SQLite/PG 成功并可读回;② **旧表**(18 列)在初始化后自动补列并写入成功;③ 补列失败时降级为 warning 且 recorder 仍能工作(SQLite `_conn` 不得因此为 None) |
|
||||||
|
| 契约(**新增,不可省**) | 断言 `TelemetryEmitter` 传给 recorder 的实参键集合 == 两个后端的 `_COLUMNS`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端的 try **之外**(`sqlite.py:90` / `postgres.py:121`),emitter 漏传新字段会抛 `KeyError`,被 `_record` 的 `except Exception` 吞成 warning → **静默丢遥测**。这是本变更最危险的失败形态,而现有 8 个 `**fields` 形态的 fake 一个都拦不住 |
|
||||||
|
|
||||||
|
> integration 层的 Redis/PG 测试遵守既有纪律:共享后端严禁并跑,`conda run -n PolyGateway --no-capture-output`。
|
||||||
|
|
||||||
|
## 9. 影响面清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `src/polygateway/types.py` | `LLMResponse` +2 字段;`TransportResult` +2 字段;`cache_hit` docstring 消歧 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | sink 采集 `model`;两处 `TransportResult` 构造填新字段;新增防御解析 helper |
|
||||||
|
| `src/polygateway/middleware/retry.py` | `_build_response` 透传 2 字段 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | `_record` + 三个 `emit_*` 各透传 2 字段;cost 换算传入 `cached_prompt_tokens` |
|
||||||
|
| `src/polygateway/pricing.py` | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验;订正唯一换算点注释 |
|
||||||
|
| `src/polygateway/ports.py` | `TelemetryRecorder` 18 → 20 字段 |
|
||||||
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||||
|
| `tests/` | 四处天然拦截点必须同步(漏改即红): 两个 `_record_minimal` 手写 18 键 dict(`unit/test_telemetry.py:76` 起、`integration/test_postgres_telemetry.py:81-105`)与两个 `_EXPECTED_COLUMNS` 列序断言(`unit/test_telemetry.py:18-40`、`integration/test_postgres_telemetry.py:22-41`);`unit/test_ports.py:96` 的全签名 fake 同步(它**不会**红,Protocol 的 isinstance 不校验签名);新增契约测试 |
|
||||||
|
| `research-wiki/ARCHITECTURE.md` | §5.1 字段表 + 遥测表定义 + 「18 字段冻结」表述 |
|
||||||
|
| 「18 字段冻结」的其余措辞点 | `ports.py:248`、`pricing.py:6`(币种说明里引用了该数字)、`telemetry/sqlite.py:87`、`tests/unit/test_telemetry.py:1` |
|
||||||
|
| Wiki 站 + `CHANGELOG.md` | 按 `docs-convention.md` §2 清单同步(公共行为变更,版本 bump 不得裸发) |
|
||||||
|
| `.env.example:56` | 该行内联注释是仓内**唯一**的价格表格式说明(无独立模板文件,`config/prices.json` 是未入库的本地文件),补 `cached_input_per_1m` 可选档 |
|
||||||
|
|
||||||
|
## 10. 审批记录
|
||||||
|
|
||||||
|
2026-07-31 人类逐条确认: **A2**(TransportResult 强类型字段)、**B1**(缓存命中原样回放 + 度量口径带 `cache_hit = false`)、**C1**(ModelPrice 可选缓存单价档)、**D1**(DDL 加列 + 初始化期幂等补列)。设计获批,进入 `writing-plans`。
|
||||||
|
|
||||||
|
版本号按 `1.1.0` 推进(纯增字段不破坏下游,但触及端口签名与表结构,minor 位比 patch 位更能提示下游);发版前若人类另有指示以指示为准。
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# 采样参数透传设计(issue #4)
|
||||||
|
|
||||||
|
- **日期**: 2026-07-31
|
||||||
|
- **状态**: 待人类审批
|
||||||
|
- **触发**: issue #4 —— `chat()` 无法设置 `temperature`/`seed`/`max_tokens`,下游受控实验无法固定解码
|
||||||
|
- **影响面**: `chat()` 公共签名、`SourceConfig` 公共类型、缓存 key 公式(ARCH §7.5)、遥测端口(20 → 21 字段)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 诉求与现状审计
|
||||||
|
|
||||||
|
下游 dissect 是一组受控实验:解码固定 `temperature=0`,每格配置跑 5 个 seed 报标准差。标准差必须只反映被研究的变量,不能混进解码随机性。
|
||||||
|
|
||||||
|
代码事实(本会话核实):
|
||||||
|
|
||||||
|
| 事实 | 位置 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全库 `temperature` 零命中 | `grep -rn temperature src/` | 解码跑在供应商默认值上,不可复现 |
|
||||||
|
| `chat()` 签名无 overlay 入口 | `client.py:143-153` | 调用方够不着 `ChatRequest.overlay` |
|
||||||
|
| `overlay` 唯一写入点是结构化中间件 | `middleware/structured.py:98` | 字段存在但只服务库内 |
|
||||||
|
| `payload.update(overlay)` 是最后一步 | `transports/openai_compat.py:297` | overlay 可覆盖 `model`/`messages`/`stream`/`stream_options` |
|
||||||
|
| 缓存 key 公式不含 overlay | `middleware/cache.py:52-64` | **见 §2 决策 C** |
|
||||||
|
| `model_fingerprint` 只由源 `model` 名算 | `client.py:117` | 配置级采样参数变更不改 key |
|
||||||
|
| minimax / openai profile 均 `thinking_off={}` | `providers.py:49,56` | `enable_thinking=False` 对两源均无效果 |
|
||||||
|
|
||||||
|
**issue 未提及但必须一并处理的**: 缓存与遥测的交互。不处理的话,failure mode 恰是 issue 自己最担心的那种——数字悄悄不可比,且不报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计决策
|
||||||
|
|
||||||
|
### 决策 A: 两层入口,合并优先级由现有层序天然给出
|
||||||
|
|
||||||
|
| 层 | 载体 | 用途 | 生效点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 调用级 | `chat(..., overlay: Mapping[str, Any] \| None = None)` | 逐次变化(每 rollout 不同的 `seed`) | 填入 `ChatRequest` |
|
||||||
|
| 配置级 | `SourceConfig.extra_body: Mapping[str, Any]` | 全局恒定(`temperature=0`) | transport `_build_payload` |
|
||||||
|
|
||||||
|
优先级 **结构化注入 > 调用级 > 配置级**,无需任何新机制:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_build_payload: payload{model,messages,stream} → thinking_profile
|
||||||
|
→ source.extra_body ← 配置级(新增一行)
|
||||||
|
→ overlay ← 调用级 ⊎ 结构化注入
|
||||||
|
StructuredMW: {**request.overlay, **strategy_overlay} ← 结构化已在最右,天然最高
|
||||||
|
```
|
||||||
|
|
||||||
|
配置级放在 transport 而非装配层合并,是因为 `extra_body` 是 per-source 的,选源在 RetryMW 之后才确定;放 transport 无需改动任何端口签名。
|
||||||
|
|
||||||
|
**`ChatRequest` 增第二个字段 `sampling: Mapping[str, Any] = field(default_factory=dict)`**(调用方原始采样意图的快照,库内中间件**永不修改**),与 `overlay`(请求体覆盖层,会被结构化注入)分开。`chat()` 同时填两者。理由是 `overlay` 在洋葱不同深度取值不同——`StructuredMW` 内侧含 `response_format`、外侧不含——缓存 key 与遥测若各自依赖"在哪一层读"就会口径分叉(见决策 C/D)。`sampling` 提供一个跨层恒定的读取点。
|
||||||
|
|
||||||
|
类型定死为 `Mapping` 而非 `dict[str, Any] | None`:空 dict 与 `None` 在此无语义差别(都是"没传采样参数"),多一种表示只会让 key 公式与 `merge()` 签名各选各的。因此决策 C 的 key 公式一律按**仅非空**参与(注意与同处的 `salt` 不同——`salt` 是"仅非 None",空串是有意义的 salt)。
|
||||||
|
|
||||||
|
### 决策 B: 保护键黑名单,构造期显式报错
|
||||||
|
|
||||||
|
`{model, messages, stream, stream_options}` 禁止出现在 overlay/extra_body 中。理由逐条:
|
||||||
|
|
||||||
|
| 键 | 被覆盖的后果 |
|
||||||
|
|---|---|
|
||||||
|
| `model` | 遥测记录的 model 与实际请求分叉 → 成本按错单价算 |
|
||||||
|
| `messages` | 缓存 key 与遥测口径同时失真 |
|
||||||
|
| `stream` | 绕过流式看门狗(TTFT/inter-token 三层超时全失效) |
|
||||||
|
| `stream_options` | 丢 usage 帧 → 成本遥测归零、TPM 闸按预扣量结算失准 |
|
||||||
|
|
||||||
|
同一校验函数还必须验**值可 JSON 序列化**。理由:`CacheMW.__call__` 第 95 行的 `build_cache_key` 内部 `json.dumps`,**不在 `_safe_get`/`_safe_set` 的降级 try 内**;`TelemetryMW` 只捕 `GatewayUnavailableError`/`GovernanceBackendError`/`CancelledError`。调用方传 `{"temperature": np.float32(0)}`(温度扫描用 numpy 生成极自然)会抛裸 `TypeError`:不属四分类、一行遥测都没有、RetryMW 从未执行。构造期一次校验即可保住"overlay 错误全部发生在进洋葱之前"这条不变式。
|
||||||
|
|
||||||
|
校验函数落在 `types.py`(最内层,无依赖),两个入口各调一次:`chat()` 参数在进洋葱**之前**校验(与既有 `structured` 的 ImportError 同款先例),`SourceConfig.__post_init__` 在装配期校验(符合 §4.5「缺失/非法关键配置直接报错」)。抛裸 `ValueError`——这是调用方编程错误,不属 §6 四分类,不应被 RetryMW 当作可重试失败。
|
||||||
|
|
||||||
|
transport 不重复校验:三个 overlay 来源(chat 参数、SourceConfig 字段、库内策略)已全部在构造期收口,库内策略只注入 `response_format`(`json_repair.py:41` 恒空,`native_schema.py:21-29` 只产该键)。
|
||||||
|
|
||||||
|
### 决策 C: 调用级 overlay 进缓存 key —— 本设计的关键点
|
||||||
|
|
||||||
|
不做的话:同 messages 跑 5 个 seed,后 4 次命中第一次的缓存,返回同一 response,**标准差恒为 0**,实验静默作废。这正是「无缓存毒化」铁律的场景。
|
||||||
|
|
||||||
|
key 公式扩展(ARCH §7.5 需同步修订),读 `request.sampling` 而非 `request.overlay`——语义明确、不依赖"CacheMW 恰在 StructuredMW 外侧"这一层序巧合:
|
||||||
|
|
||||||
|
```text
|
||||||
|
key_obj = {model, messages_digest, namespace, [salt], [sampling]}
|
||||||
|
仅非 None 仅非空
|
||||||
|
```
|
||||||
|
|
||||||
|
沿用 `salt` 的「仅非空时参与」写法,保证**空采样参数时旧键逐字不变**,不触发存量缓存全量冷启动。
|
||||||
|
|
||||||
|
配置级同理:`model_fingerprint` 从 `",".join(sorted(models))` 扩展为——所有源 `extra_body` 皆空时字面不变;否则追加 `"|" + sha256(...)`,摘要对象是「每个源的 `(model, extra_body)` 先各自 canonical-JSON 化成字符串,再排序去重」(dict 本身既不可排序也不可哈希,必须先序列化;`extra_body` 若存为 `MappingProxyType` 需 `dict(...)` 后再 `json.dumps`)。取 `(model, extra_body)` 而非 `(name, ...)`,语义是「本 scope 会用哪些(模型,解码参数)组合」,改源名不会误触冷启动。该计算在 `client.py:117` 且不在任何降级 try 内,写错即装配期崩——实施时须有直接单测。
|
||||||
|
|
||||||
|
**两条已知副作用(须写进 wiki)**:
|
||||||
|
|
||||||
|
1. 逐 rollout 变化的 `seed` 进 key 后,该路径**天然全部 miss**。这是正确语义而非缺陷,但下游要知道缓存对这条路径不再省钱。
|
||||||
|
2. `model_fingerprint` 是**集合级**指纹,不是本次实际选中源的指纹。同 scope 下各源 `extra_body` 不同时,缓存仍可能返回另一源、另一组解码参数下产生的响应。这是既有取舍的延续(`cache.py:68-72` 对 `model` 已如此),不是本设计引入的新缺口,但"配置级采样参数进 key"容易被读成更强的保证,须写明边界。受控实验若要求逐源可复现,应让每个源独享 scope 或 namespace。
|
||||||
|
|
||||||
|
### 决策 D: 采样参数入遥测(端口 20 → 21 字段)
|
||||||
|
|
||||||
|
「实验可复现」的另一半是参数落库。不记的话,同 messages 不同输出在审计表里无法解释。与 issue #3 新增 `model_reported` 同类动机(供应商把别名指向新权重时,复现必须认真实串)。
|
||||||
|
|
||||||
|
**列语义定死**:`sampling: str | None` = 「调用方采样意图 ⊎ 生效源的 `extra_body`」的 canonical JSON,**不含库内结构化注入的 `response_format`**。两个理由:该列名叫采样参数,`response_format` 不是;schema 可达数 KB,逐行记会让审计表无谓膨胀。
|
||||||
|
|
||||||
|
`TelemetryEmitter` 有三个入口且都汇入同一个 `_record`(显式关键字参数,加列必须三处都传),必须逐个定死,否则同一列在不同行口径分叉——这正是 1.0.4 里 `cached_prompt_tokens` 不得不写"下游请读"警告的同类坑:
|
||||||
|
|
||||||
|
| 入口 | 调用者 | 有 `source`? | `sampling` 记什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
||||||
|
| `emit_cache_hit` | TelemetryMW(最外) | **无** | 仅 `request.sampling` |
|
||||||
|
| `emit_terminal_failure` | TelemetryMW | **无** | 仅 `request.sampling` |
|
||||||
|
|
||||||
|
后两行缺 `extra_body` 是**客观事实而非口径瑕疵**:它们没有"生效源"可言——与 `model`/`provider`/`source_name` 在终态行置空是同一先例。缓存命中行尤其无损:`sampling` 已进缓存 key,能命中就意味着历史那次的调用级采样参数与本次逐字相同;`extra_body` 亦已进 `model_fingerprint`,命中意味着源集合的配置指纹相同。
|
||||||
|
|
||||||
|
三个入口统一读 `request.sampling`(决策 A 的新字段)而非 `request.overlay`,是因为后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处则未被污染,直接用会让三行天然分叉。
|
||||||
|
|
||||||
|
**共用范围写清楚**:`types.py` 提供「2 参 dict 合并 + canonical 序列化」这一个原语,transport 与 emitter 共用它。**不追求统一到两者之上**——transport 是往更大的 payload 上依次 `update(thinking_profile) → update(extra_body) → update(overlay)`,emitter 算的是 `merge(extra_body, sampling)`,参与方与顺序本就不同,强行统一是错的。这不影响正确性:该列语义已定义为「调用方意图 ⊎ 生效源 `extra_body`」,而非 payload 的逐字回显。共用原语的目的只是让"合并语义与序列化口径"这一件事不出现两份实现。
|
||||||
|
|
||||||
|
两个后端按 issue #3 已建立的套路幂等补列:**先探测缺列再 ALTER**、失败只逐行降级不置结构性失能标志、新列排在 `created_at` 之后。
|
||||||
|
|
||||||
|
一次做完而非分两步:「能传参数但没记」的中间状态最危险——数据已产生且事后无法追溯,且分步要做两遍 DDL 迁移。
|
||||||
|
|
||||||
|
**OCR/Embedding 的 emit 调用点零改动**:`ocr.py:418` 与 `embedding.py:372` 也调 `emit_attempt` 且都传 `source`,只要 `sampling` 由 emitter 内部推导(而非作为新必填参数由调用者传入),这两处调用不动一行——反之立刻 TypeError,实施时必须走推导路线。两个文件本身仍有改动,即决策 G 的构造期剥离(它正是让这里的推导对 OCR/embedding 恒得 NULL 的前提)。
|
||||||
|
|
||||||
|
### 决策 E: 入参拷贝语义与两条只读约束
|
||||||
|
|
||||||
|
`chat()` 对传入 overlay 做**一次** `dict(overlay)` 浅拷贝,同一份快照对象同时填 `overlay` 与 `sampling` 两个字段(不做两份独立拷贝——它们在进入 `StructuredMW` 之前本就应当逐字相同,两份拷贝反而给"两者可以分叉"留了口子)。
|
||||||
|
|
||||||
|
issue 场景就是逐次改 `seed`——调用方复用同一 dict 对象改值是极可能的模式,不拷贝会出现「请求已发出、key 用了新 seed」的竞态。`ChatRequest` 虽 frozen 但 dict 是浅冻结,拦不住。`SourceConfig.extra_body` 在 `__post_init__` 转 `MappingProxyType` 同理(成本近零)。
|
||||||
|
|
||||||
|
拷贝之外的第二条约束:**任何中间件不得就地修改这两个 dict**,只能经 `dataclasses.replace` 派生新请求。现状已满足(`StructuredMW` 用 `{**a, **b}` 生成新 dict,`_build_payload` 只往 payload 上 `update`,全库无就地改写),本设计只是把它写成明文约束——决策 C 与 D 都建立在 `sampling` 跨层恒定之上,这条被破坏则两者同时失效(测试 #14 为此加机械执法)。
|
||||||
|
|
||||||
|
### 决策 F: 空 thinking profile 的诚实性缺口(issue 附带项)
|
||||||
|
|
||||||
|
`minimax` 与 `openai` 的 `thinking_on/thinking_off` 均为空字典。`providers.py:52` 那条「OpenAI 兼容基线,无已知注入差异」的注释在词法上属于紧随其后的 **minimax** 条目,`openai` 条目没有任何注释。所以现状是:已有的注释解释了"为何为空",但两个 provider 都没点明**后果**——`enable_thinking=False` 对它们不产生任何效果,调用方以为关掉了实际没关。
|
||||||
|
|
||||||
|
补的是这一句后果说明(覆盖两个 provider),不是重复已有的"为何为空"。不改行为:真需要关时经 `extra_body` 绕过。
|
||||||
|
|
||||||
|
### 决策 G: 非 chat 路径的 `extra_body` —— 剥离并 warning,不中断装配
|
||||||
|
|
||||||
|
`_SOURCE_FIELDS`(`config.py:33-47`)是**跨 scope 共用**的一张表,加了 `EXTRA_BODY` 之后 `OCR__MONKEY__1__EXTRA_BODY` / `EMBED__QWEN__1__EXTRA_BODY` 会被合法接受、进 `SourceConfig`、进遥测 `sampling` 列,但两条路径都不消费它:`monkey_ocr.py:225,247` 只发 multipart `files=`(**根本没有 JSON body**),`OpenAICompatTransport.embed`(`openai_compat.py:343`)payload 硬编码 `{"model", "input"}`。放任即**静默无效**,正是 §4.5 要禁的形态。
|
||||||
|
|
||||||
|
**处置(2026-07-31 人类拍板改此档)**:`EmbeddingClient` / `OcrClient` 构造期发现源带非空 `extra_body` → 记 warning 并 `dataclasses.replace(source, extra_body={})` **剥离后放行**,不抛异常。
|
||||||
|
|
||||||
|
剥离是这一档的**必要组成部分,不是顺手清理**。`ocr.py:390` 与 `embedding.py:350` 构造 `ChatRequest` 时不带 `sampling`,但传给 `emit_attempt` 的 `source` 是真实配置对象;若不剥离,决策 D 的 `merge(source.extra_body, request.sampling)` 会让遥测**记录一个从未发出的参数**——审计表显示该次 OCR 调用带了 `temperature=0`,实际请求体里没有。那不是"参数不生效",是遥测造假,污染的恰是事后复现的唯一依据。替代方案是在 emitter 里特判调用方身份,直接违背「遥测调用点收敛为单一 helper」铁律,否决。
|
||||||
|
|
||||||
|
剥离后该列在 OCR/embedding 行恒为 NULL,语义干净,emitter 零特判。
|
||||||
|
|
||||||
|
**被否决的原方案**: 装配期 `ValueError` 直接拒绝。理由是这两条路径本无采样语义,配错的后果远轻于 chat 路径,不值得让下游整个装配起不来。**残余风险须写进 wiki**: loguru warning 在生产中容易被淹没,运维可能仍以为参数生效——这是"不中断装配"换来的代价,故 warning 文案必须**指路**:`dimensions` 是 OpenAI embeddings 的正式参数,下游想调向量维度时会第一个撞上,文案应写明"embedding 路径暂不支持 `extra_body`,该配置已被忽略;需要 `dimensions` 等参数请提 issue"。
|
||||||
|
|
||||||
|
不顺手给 embed 加透传:embedding 没有采样一说,issue 也未提出诉求(YAGNI);真有需求时单独设计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 关键岔路与否决记录
|
||||||
|
|
||||||
|
| 岔路 | 否决方 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| `chat()` 展开为 `temperature=`/`seed=`/`max_tokens=` 具名参数 | 否决 | 供应商私有参数无穷尽(`top_k`/`repetition_penalty`/`thinking_budget`),具名等于永久追加签名;且违背「深模块窄接口」(ARCH §132) |
|
||||||
|
| 配置级放装配层全局字典而非 `SourceConfig` | 否决 | 采样参数与源强相关(不同供应商键名不同),全局字典会把无效键发给不认识它的源 |
|
||||||
|
| overlay 不进缓存 key,靠调用方传 `cache_salt` 区分 | 否决 | 把毒化防护的责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态 |
|
||||||
|
| 采样参数不入遥测,由下游 run 快照自记 | 否决 | 见决策 D |
|
||||||
|
| 缓存 key 与遥测都直接读 `request.overlay`,不加 `sampling` 字段 | 否决 | `overlay` 在洋葱不同深度取值不同(结构化注入),三个 emit 入口与 CacheMW 会各记各的,同一列口径分叉 |
|
||||||
|
| `sampling` 列记「实际发出的完整合并结果」(含 `response_format`) | 否决 | 该列名为采样参数,schema 不是;且数 KB schema 逐行落库无谓膨胀 |
|
||||||
|
| 给 embedding 路径也加 `extra_body` 透传 | 否决 | embedding 无采样一说,issue 未提诉求(决策 G) |
|
||||||
|
| 非 chat 路径带 `extra_body` 时装配期 `ValueError` | 否决(人类拍板) | 这两条路径无采样语义,配错后果远轻于 chat,不值得让下游装配起不来;改为剥离 + warning |
|
||||||
|
| 允许放行但**不剥离** `extra_body` | 否决 | 遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`),是数据造假而非参数失效 |
|
||||||
|
| 放行不剥离,改在 emitter 内特判 OCR/embedding 不记 | 否决 | emitter 是「遥测调用点收敛单一 helper」的产物,让它识别调用方身份是开倒车 |
|
||||||
|
| transport 层再兜一次保护键校验 | 否决 | 三个入口已构造期收口,重复校验属 gold-plating |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
|---|---|
|
||||||
|
| **并发** | 无新增共享状态。`extra_body` 装配后只读(MappingProxyType);调用级 overlay 每调用独立拷贝,并发调用互不可见 |
|
||||||
|
| **取消** | 无新增 await 点与等待循环,`CancelledError` 穿透路径完全不变 |
|
||||||
|
| **降级方向** | 不涉及新后端。遥测新列写失败沿用既有逐行 warning 降级;缓存 key 变更不影响 Redis 掉线的静默降级方向。决策 G 的剥离 + warning 是**配置面**降级(装配期一次性、可复现、部署即暴露),与铁律里"限流/熔断后端不可用须报错"的**运行时**降级方向是两回事,不冲突 |
|
||||||
|
| **幂等与重复** | 保护键校验是纯函数,重复调用安全;遥测补列先探测后 ALTER,重启幂等 |
|
||||||
|
| **持久化与原子性** | 遥测单行写入,无部分写入风险。缓存 value 结构不变(`sampling` 只进遥测不进 `LLMResponse`,避免动已被三项目消费的公共类型) |
|
||||||
|
| **重试交互** | overlay 在 RetryMW 循环外确定,换源重试时同一 overlay 应用到新源的 `extra_body` 之上——语义正确(调用级意图跨源保持) |
|
||||||
|
| **限流交互** | overlay 里的 `max_tokens` 不影响入场预扣(取 `effective_est_tokens()`)。调用方把 `max_tokens` 抬到远超预扣量时 TPM 入场保护会短暂失真,结算侧(`retry.py:338-343`)按实测用量回填自愈。已知且可接受,不为此加机制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 错误处理与测试策略
|
||||||
|
|
||||||
|
**错误分类**: 保护键违规与 `EXTRA_BODY` JSON 解析失败均为裸 `ValueError`,发生在进入洋葱之前/装配期,不入四分类、不触发重试或熔断。运行时若供应商拒绝某个采样参数(如不支持 `seed`),网关返回 4xx,由既有 `RequestRejectedError` 路径处置——无需新增分类。
|
||||||
|
|
||||||
|
**测试清单**(每条须先失败后通过):
|
||||||
|
|
||||||
|
| # | 用例 | 层 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 同 messages 不同 `seed` → 两次 miss、两个不同 key(issue 场景直接回归) | unit |
|
||||||
|
| 2 | 空 overlay 时 key 与旧实现逐字相同(防存量冷启动) | unit |
|
||||||
|
| 3 | 全源 `extra_body` 为空时 fingerprint 与旧实现逐字相同 | unit |
|
||||||
|
| 4 | 保护键:`chat(overlay={"stream": False})`、`SourceConfig(extra_body={"model": "x"})` 均 `ValueError` | unit |
|
||||||
|
| 5 | 优先级:配置 `temperature=0` + 调用级 `temperature=1` → payload 为 1;结构化 `response_format` 覆盖调用级同名键 | unit |
|
||||||
|
| 6 | 调用方在 `chat()` 返回前修改自己的 dict,不影响已发请求与已算 key(拷贝语义) | unit |
|
||||||
|
| 7 | env 解析:`EXTRA_BODY` 合法 JSON 对象 → dict;非法 JSON / 非对象 → `ValueError` | unit |
|
||||||
|
| 8 | 不可 JSON 序列化的值(如 `np.float32`)在 `chat()` 入口即 `ValueError`,不进洋葱 | unit |
|
||||||
|
| 9 | 三个 emit 入口的 `sampling` 口径:attempt 含 `extra_body`、cache_hit 与 terminal 只含调用级、结构化注入的 `response_format` **三行都不出现** | unit |
|
||||||
|
| 10 | `EmbeddingClient`/`OcrClient` 装配时源带 `extra_body` → 记 warning、装配成功、源上 `extra_body` 已被剥空,且该路径遥测 `sampling` 为 NULL(决策 G;后半段是防遥测造假的真正断言) | unit |
|
||||||
|
| 11 | `_EXPECTED_COLUMNS` 断言更新后仍逐字匹配实际列序(见 §6,两处会直接红) | unit + integration |
|
||||||
|
| 12 | 遥测 `sampling` 落库正确;两后端对既有旧表幂等补列 | integration |
|
||||||
|
| 13 | 采样参数经全链路(chat → 选源 → transport payload)到达请求体 | integration |
|
||||||
|
| 14 | **地基不变式**:走结构化重问阶梯(至少重问一次)后,RetryMW 每次尝试看到的 `request.sampling` 与 `chat()` 传入值逐字相同,且同一时刻 `request.overlay` 含 `response_format` | unit |
|
||||||
|
|
||||||
|
第 14 条是决策 C/D 共同的承重前提。它现在只靠"`dataclasses.replace` 恰好保留未提及字段"这一约定成立,无任何机械执法;缺这条测试则决策 E 的只读约束被破坏时不会有人发现。
|
||||||
|
|
||||||
|
第 2 条(空采样参数时旧键逐字不变)需自行先固化旧 key 值再比对——现有 `tests/unit/test_cache.py:39-54` 只有相等/不等与前缀断言,没有 golden hash 可依。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置与文档同步
|
||||||
|
|
||||||
|
env 键名沿用既有约定:`{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,值为 JSON 对象串;`_SOURCE_FIELDS` 增一项、`_cast` 增 `json` 分支(解析失败与非 dict 均报错)。
|
||||||
|
|
||||||
|
同步清单(docs-convention §2):
|
||||||
|
|
||||||
|
| 目标 | 改什么 |
|
||||||
|
|---|---|
|
||||||
|
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点 |
|
||||||
|
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用 |
|
||||||
|
| ARCH §7.7 | 该节逐字段枚举 `SourceConfig` 构成(`ARCHITECTURE.md:452`),补 `extra_body` |
|
||||||
|
| ARCH §7.8 | 必录字段 20 → 21 |
|
||||||
|
| ARCH §9 | 配置面键族事实源(`:519-527`),登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
|
||||||
|
| `.env.example` | `client.py:247` docstring 声明它是键名清单的事实源,新键不写进去等于无处可查 |
|
||||||
|
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` |
|
||||||
|
| wiki how-to | 增「固定解码参数」条目,写明 seed 进 key 导致缓存必 miss、以及 OCR/embedding 路径的 `extra_body` 会被忽略(仅 warning) |
|
||||||
|
| CHANGELOG | 公共 API 新增 + 遥测端口扩列 |
|
||||||
|
|
||||||
|
## 7. 实施范围
|
||||||
|
|
||||||
|
`types.py`(保护键与 JSON 可序列化校验、合并纯函数、`ChatRequest.sampling`、`SourceConfig.extra_body`)、`client.py`(`chat()` 参数 + fingerprint)、`middleware/cache.py`(key 公式)、`transports/openai_compat.py`(`_build_payload` 一行)、`config.py`(env 解析)、`ports.py` + `middleware/telemetry.py` + `telemetry/{sqlite,postgres}.py`(第 21 字段与补列)、`ocr.py` + `embedding.py`(仅决策 G 的构造期剥离 + warning)、`providers.py`(注释)。
|
||||||
|
|
||||||
|
**测试侧必改**(否则直接红):`tests/unit/test_telemetry.py:18,113` 与 `tests/integration/test_postgres_telemetry.py:22,210,231` 的 `_EXPECTED_COLUMNS` 断言完整列表与列序。
|
||||||
|
|
||||||
|
无需改动:import-linter 契约(校验函数落最内层 `types.py`,分层关系不变)。
|
||||||
|
|
||||||
|
不做:给 embedding/OCR 加采样参数透传(决策 G)、任何任务外重构。
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-08-02-thinking-capability-design
|
||||||
|
title: "推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)
|
||||||
|
|
||||||
|
> 类型:design|日期:2026-08-02|状态:待人类确认
|
||||||
|
> 事实基础见 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`(本文所有实测引用均出自该文)。
|
||||||
|
> 本设计经 2026-08-02 充分讨论后直接给出单一方案,不列备选。
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
**issue #5——静默失效。** `SourceConfig.enable_thinking` 是给上层的统一推理开关,靠 `providers.py` 的 `ProviderProfile.thinking_on/thinking_off` 落地。`minimax` 与 `openai` 两格皆为空 dict,`_build_payload` 的 `payload.update({})` 是空操作:`enable_thinking=False` 对这两类源**完全不产生效果**,而配置方以为关掉了。
|
||||||
|
|
||||||
|
这不是理论缺陷。`dissect/.env:84,99` 两个 scope 均写 `ENABLE_THINKING=false`,并在 `:67-70` 记为明确阻塞项——Phase-0 要求关闭思维链以隔离变量。
|
||||||
|
|
||||||
|
**issue #6——归因缺口。** `usage.completion_tokens_details.reasoning_tokens` 未被采集。成本总额正确(推理 token 已含在 `completion_tokens` 内),但"本次调用有多少钱花在推理上"无法区分,而这正是 dissect 要测的因子的主要成本通道。
|
||||||
|
|
||||||
|
**两者的耦合。** #6 是 #5 的验收仪器:修完 #5 后判断"这次是否真的没推理",靠正文长度不可靠,靠 `reasoning_content` 也不行(MiniMax 非流式恒为空、正文无 `<think>` 标签)。因此 **#6 先落地,#5 的测试断言它**。
|
||||||
|
|
||||||
|
## 2. 根因
|
||||||
|
|
||||||
|
空 dict 同时承载了两种语义:「本 provider 无需注入任何参数」与「我们不知道本 provider 怎么表达」。二者混同,就只能靠"表里没有 = 不发"兜底,静默失效随之产生。
|
||||||
|
|
||||||
|
更深一层:`ProviderProfile` 的注册单位是 **provider**,而"能否关闭推理"是 **model** 的属性。实测证明同一 provider 内部代际差异是决定性的——MiniMax-M3 可关,M2.7 / M2.5 **固有不可关**(三种参数形态实测全部无效,OpenRouter 与 models.dev 独立登记为 mandatory)。provider 级的表在物理上表达不了这件事。
|
||||||
|
|
||||||
|
业界佐证:注册单位下沉到 model 级的(LiteLLM、models.dev、LangChain、OpenRouter、Helicone)都有显式失败通道;仍停在 provider 级的(Portkey、LlamaIndex)恰是失败语义最差的两家,均静默丢弃。**注册粒度与失败语义是同一个问题的两面。**
|
||||||
|
|
||||||
|
## 3. 决策摘要
|
||||||
|
|
||||||
|
| # | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| D1 | **形态留 provider 级,能力下沉 model 级**。形态 = 参数长什么样(数年不变);能力 = 能否关闭(每代都变) |
|
||||||
|
| D2 | **「未知 / 不支持 / 不干预」必须是三个不同的值**,落在三个不同层次 |
|
||||||
|
| D3 | **遇到"关不掉"的模型报错,不静默放行**;报错在装配期,请求期兜底 |
|
||||||
|
| D4 | **「开」的默认档定 `medium`,允许 per-source 覆盖**(经已有 `extra_body`,不新增字段) |
|
||||||
|
| D5 | `enable_thinking` **纳入缓存指纹**(配套,必做) |
|
||||||
|
| D6 | `reasoning_tokens` 的文档措辞为「**本次调用**未上报」,非「该源未上报」(配套,必做) |
|
||||||
|
|
||||||
|
D4 的依据:业界对「开」映射到哪一档**无语义共识**(LiteLLM 用 2 的幂、OpenRouter 用百分比、Helicone 一律折半),唯一的工程共识是**该映射必须是可覆盖的常量**。选 `medium` 是因为 qwen 的 `enable_thinking:true` 与 deepseek 的 `thinking:{enabled}` 都不指定预算、由模型自定,`medium` 是五档中语义最接近"厂商正常强度"的一档;选 `high` 等于库替所有下游做"加钱换质量"的业务判断,违反零业务假设。
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
### 4.1 形态层(provider 级)
|
||||||
|
|
||||||
|
`ProviderProfile` 两档由 `dict` 放宽为 `dict | None`:
|
||||||
|
|
||||||
|
| 值 | 含义 | 当前实例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `{...}` | 已知的注入片段 | qwen / deepseek / minimax |
|
||||||
|
| `{}` | 已知**无需注入**即处于该档 | 无(保留为自然零值) |
|
||||||
|
| `None` | **未知**:库不知道该 provider 如何表达 | `openai` 两档 |
|
||||||
|
|
||||||
|
```python
|
||||||
|
"minimax": ProviderProfile(
|
||||||
|
name="minimax",
|
||||||
|
thinking_on={"reasoning_effort": "medium"},
|
||||||
|
thinking_off={"reasoning_effort": "none"},
|
||||||
|
strip_think_tags=False,
|
||||||
|
),
|
||||||
|
"openai": ProviderProfile(
|
||||||
|
name="openai", thinking_on=None, thinking_off=None, strip_think_tags=False,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
`openai` 填 `None` 而非补 `reasoning_effort`,理由是该段名在实践中已被复用为**任意 OpenAI 兼容厂商的兜底**(`dissect/.env:116` 把 `kimi-k3` 挂在 `provider=openai` 下)。向未知厂商下发 `reasoning_effort` 会招致 400;标为未知则让误配在装配期显式暴露。真·OpenAI 推理模型的使用者走 `register_provider`——这正是 D11 承诺的"新 provider = 一个条目"。
|
||||||
|
|
||||||
|
qwen / deepseek 两条实测正确,**不动**。
|
||||||
|
|
||||||
|
### 4.2 能力层(model 级,新增)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
```
|
||||||
|
|
||||||
|
登记表键为模型名精确匹配,**只登记在用的模型**,未登记即"未知"并走退化路径:
|
||||||
|
|
||||||
|
| 模型 | `can_disable` | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||||
|
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter `mandatory:true` |
|
||||||
|
| `MiniMax-M2.5` | `False` | 同上 |
|
||||||
|
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||||
|
| `deepseek-v4-pro` | `True` | 实测 `thinking:{disabled}` 关闭 |
|
||||||
|
|
||||||
|
注入方式沿用 D11 的纯函数注册纪律:`get_capability(model, *, table=None)` 与 `register_capability(...)` 返回新表,经 `capabilities` 参数注入,与现有 `registry` 参数同形,**不引入模块级可变状态**。
|
||||||
|
|
||||||
|
**不引入 models.dev / LiteLLM 的 JSON 作为运行时依赖**——违反依赖极简与纯 asyncio 中立(import 期发网络请求)。二者仅作为写表时的对照参考;本次三条 MiniMax 实测与它们的登记 100% 吻合,这本身就是表可信的旁证。
|
||||||
|
|
||||||
|
### 4.3 三个值的层次归属(D2)
|
||||||
|
|
||||||
|
| 语义 | 载体 | 层次 |
|
||||||
|
|---|---|---|
|
||||||
|
| **不干预**(调用方不表态) | `SourceConfig.enable_thinking is None` | 调用方意图 |
|
||||||
|
| **未知**(库不知道怎么表达) | `ProviderProfile` 该档为 `None` | 形态层 |
|
||||||
|
| **不支持**(模型做不到) | `ThinkingCapability.can_disable is False` | 能力层 |
|
||||||
|
|
||||||
|
三者不可互相替代:不干预是意图缺失,未知是知识缺失,不支持是能力缺失。当前实现把后两者塌缩成空 dict,是 issue #5 的根因。
|
||||||
|
|
||||||
|
## 5. 判定与失败语义(D3)
|
||||||
|
|
||||||
|
单一判定函数收口,形态层与能力层在此相遇:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def resolve_thinking(profile, capability, enable_thinking) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(由调用点翻译为领域错误)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
真值表:
|
||||||
|
|
||||||
|
| # | 条件 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 | `enable_thinking is None` | 不注入。与 `False` 严格区分 |
|
||||||
|
| R2 | 形态层该档为 `None` | **报错**,文案指路 `register_provider` 或 `extra_body` |
|
||||||
|
| R3 | `enable_thinking is False` 且 `can_disable is False` | **报错**:调用方要的是"不推理"的语义保证,给不了必须说 |
|
||||||
|
| R4 | 模型未登记(能力未知) | 按形态层注入 + `loguru.warning`,不阻断 |
|
||||||
|
| R5 | 其余 | 按形态层注入 |
|
||||||
|
|
||||||
|
R3 与 R4 的极性相反,这是刻意的,借鉴 LiteLLM 的两极性纪律:**"关不掉"用错的后果是下游带着错误前提做实验(opt-in,从严);"未登记"多为新模型上线(opt-out,从宽)**,误拒会让库成为升级路上的绊脚石。
|
||||||
|
|
||||||
|
### 5.1 报错位置:两处,共用同一份判定
|
||||||
|
|
||||||
|
| 位置 | 异常 | 覆盖 |
|
||||||
|
|---|---|---|
|
||||||
|
| `client.py:from_settings`(`:248` 已在此解析 profiles) | `ValueError`(装配期) | `from_env` / `from_settings` 两条工厂路径,即 90% 场景 |
|
||||||
|
| `OpenAICompatTransport` | `RequestRejectedError`(四分类之一,不重试不换源) | 构造函数全量注入路径 |
|
||||||
|
|
||||||
|
这不是重复判定:`get_provider` 现在就是同一形态(`client.py:248` + `openai_compat.py:313`)。双点校验的必要性来自 issue #1 的教训——**装配守卫必须任何构造路径都生效**。
|
||||||
|
|
||||||
|
**绝不在 `_build_payload` 里抛裸 `ValueError`**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||||
|
|
||||||
|
## 6. reasoning_tokens 采集(issue #6)
|
||||||
|
|
||||||
|
照搬 issue #3 的 `_coerce_cached_tokens` 形态:只收非负整数,显式排除 `bool`(`isinstance(True, int)` 为真,放行会把 `True` 记成 1)。
|
||||||
|
|
||||||
|
`LLMResponse` / `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`——字段顺序是公共承诺(`types.py:1-5`),只增不删不改名。
|
||||||
|
|
||||||
|
流式与非流式对称取值:`completion_tokens_details` 在最后的 usage 帧里,`missing_done="salvage"` 打捞路径拿不到时记 `None` 而非 `0`(现有代码天然满足:`sink` 无 usage 时 `_coerce_*` 返回 `None`)。
|
||||||
|
|
||||||
|
**`pricing.py` 一行不改**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。这是归因缺口,不是计费缺口。
|
||||||
|
|
||||||
|
**缓存路径无需改动**:`CacheMW._rehydrate` 按 `_RESPONSE_FIELDS` 动态过滤(`cache.py:28,133`),旧条目缺该字段自动落 `None`,语义正确。
|
||||||
|
|
||||||
|
### 6.1 语义澄清(D6)
|
||||||
|
|
||||||
|
实测三家在未推理时都是**整个 `completion_tokens_details` 对象缺失**,无一上报 `0`。且 new-api 在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage,把 ctd 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。因此:
|
||||||
|
|
||||||
|
- docstring 写「**本次调用**未上报」,**不可**写「该源未上报」
|
||||||
|
- 下游判据必须是 `reasoning_tokens in (None, 0)`,写 `== 0` 的条件永远不成立
|
||||||
|
- 这三句要同时进 docstring、CHANGELOG 与 wiki
|
||||||
|
|
||||||
|
## 7. 缓存指纹配套(D5)
|
||||||
|
|
||||||
|
`build_model_fingerprint`(`client.py:63-80`)当前只摘要 `(model, extra_body)`。#5 一旦让 thinking 真正改变请求体,就会出现"关掉推理后重启读到开着推理时的旧缓存"——issue #4 为 `temperature` 写过逐字相同的理由。
|
||||||
|
|
||||||
|
做法:marks 的判据由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`,摘要对象并入该值。**全源不配 `enable_thinking` 时字面量与现值逐字相同,不触发存量缓存冷启动**;dissect 会有一次性冷启动,这是正确行为(旧缓存来自推理开着的调用)。
|
||||||
|
|
||||||
|
## 8. 落点清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `providers.py` | 两档放宽为 `dict \| None`;填 minimax、`openai` 改 `None`;新增 `ThinkingCapability` / `DEFAULT_CAPABILITIES` / `get_capability` / `register_capability` / `resolve_thinking` |
|
||||||
|
| `transports/openai_compat.py` | `_build_payload` 两分支收敛为一行 `resolve_thinking(...)`;新增 `_coerce_reasoning_tokens`;流式 `:401` 与非流式 `:485` 填值;构造函数收 `capabilities` |
|
||||||
|
| `client.py` | `from_settings` / `from_env` 加 `capabilities`;`:248` 后加装配守卫;`build_model_fingerprint` 纳入 `enable_thinking` |
|
||||||
|
| `types.py` | `LLMResponse` / `TransportResult` 尾部加 `reasoning_tokens` |
|
||||||
|
| `middleware/retry.py` | `_build_response` 透传 |
|
||||||
|
| `ports.py` | `record_llm_call` 21 → 22 字段 |
|
||||||
|
| `telemetry/{sqlite,postgres}.py` | 建表列 + `_BACKFILL_COLUMNS` 迁移 + `_COLUMNS`,**新列排末尾**(两处注释均有明文要求) |
|
||||||
|
| `middleware/telemetry.py` | `_record` + 三个 `emit_*` 入口 |
|
||||||
|
|
||||||
|
## 9. 测试策略
|
||||||
|
|
||||||
|
本次改动的正确性**与具体模型强相关**,mock 只能验证代码路径、无法验证"这个参数在这个模型上是否真的关掉了推理"。因此核心行为**必须由真实 API 多轮调用验证**。
|
||||||
|
|
||||||
|
### 9.1 三层分工
|
||||||
|
|
||||||
|
| 层 | 内容 | 是否门控合并 |
|
||||||
|
|---|---|---|
|
||||||
|
| unit | `resolve_thinking` 真值表(R1–R5)、`_coerce_reasoning_tokens` 形态防御、注入优先级、装配守卫报错、缓存指纹变化与不变性 | **是**(CI 可跑) |
|
||||||
|
| integration | 遥测两后端新列写入与 ALTER 迁移 | **是** |
|
||||||
|
| **e2e(真实 API)** | 见 9.2 | 打 `slow` 标记被默认排除;**合并前必须 `-m slow` 真跑并存档报告** |
|
||||||
|
|
||||||
|
不让本组阻断 CI 的理由是外部不可用会误伤:实测中 kimi 渠道在 429 后被中转下线并返回 404,另有一次 `network_error` 连续三次耗尽源导致 L2 假红。让外部波动阻断合并,会把测试变成噪声源。
|
||||||
|
|
||||||
|
**实现机制**:给本组打项目既有的 `slow` 标记。`pyproject.toml` 的 `addopts = "-m 'not slow'"` 默认排除它(该配置的注释原文:「慢速测试,CI 按需跑」),合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。实测效果:`make ci` 由 7 分钟降至 91 秒。
|
||||||
|
|
||||||
|
**一处必须澄清的事实**:`make test` 跑的是 `pytest tests/`,**包含 `tests/e2e/`**——只要 `.env` 有凭据,既有的轻量 e2e 冒烟就会真跑。所以「e2e 不进 CI」这句对本项目**并不成立**,只有打了 `slow` 的才被排除;本节初稿写成前者,是错的。「不自动门控」也不等于「可跳过」——沿用既有口径(`tests/e2e/test_smoke_gateway.py:22` 的 reason 写着「验收前必须真跑」)。
|
||||||
|
|
||||||
|
### 9.2 e2e 覆盖矩阵
|
||||||
|
|
||||||
|
沿用既有 e2e 约定:`dotenv_values(".env")` + `pytestmark = pytest.mark.skipif(not _HAS_SOURCE, ...)`,结构化报告输出至 `tests/outputs/e2e/`。
|
||||||
|
|
||||||
|
| # | 场景 | 源 | 轮数 | 判据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| L1 | `enable_thinking=False` | MiniMax-M3 | ≥10 | 每轮 `completion_tokens < 30` 且 `reasoning_tokens` 恒 `None` |
|
||||||
|
| L2 | `enable_thinking=True` | MiniMax-M3 | ≥10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||||
|
| L3 | `enable_thinking=None` | MiniMax-M3 | ≥10 | 不注入任何 thinking 参数(基线) |
|
||||||
|
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | ≥5 | 实发 `high`,profile 的 `medium` 被覆盖 |
|
||||||
|
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 ≥10 | 同 L1 / L2(库默认 `stream=True`,这是主路径) |
|
||||||
|
| L6 | `enable_thinking=False` | qwen | ≥10 | 关闭 |
|
||||||
|
| L7 | `enable_thinking=False` | deepseek | ≥10 | 关闭 |
|
||||||
|
| L8 | **能力表漂移哨兵** | 全部登记模型 | 各 ≥5 | 实测行为与 `can_disable` 声明一致 |
|
||||||
|
| L9 | `enable_thinking=False` + M2.7 → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||||
|
|
||||||
|
轮数由环境变量可调高,默认 ≥10。总量约 100–150 次调用。
|
||||||
|
|
||||||
|
### 9.3 三条必须遵守的测试纪律
|
||||||
|
|
||||||
|
**(a)判别量只能是 `reasoning_tokens`。**(2026-08-02 e2e 实测修正:本节初稿写的是"主判据用 `completion_tokens`",被数据推翻。)两档的输出长度分布**重叠**——关闭档实测最高 46(模型偶尔把解题过程写进正文),开启档最低 13(medium 档想得少的轮次),按长度阈值判两个方向都会误判;而 `reasoning_tokens` 在同一批 30 轮里干净分开。`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路。另配一个不含魔数的确定性锚点:关闭档 `prompt_tokens` 严格小于开启档(实测 194 < 207)。
|
||||||
|
|
||||||
|
**(b)多轮 + 计数判定,不用单轮判定。** 关闭方向要求**每轮**都满足(关掉后 `completion_tokens` 极稳定,实测 4–10);开启方向只要求**多数轮**满足(推理量方差大)。
|
||||||
|
|
||||||
|
**(c)源不可用必须跳过并显式记录为"未覆盖",不得静默计入通过。** 报告里要能一眼看出哪些矩阵行没跑到。
|
||||||
|
|
||||||
|
### 9.4 漂移哨兵(L8)的定位
|
||||||
|
|
||||||
|
能力表过期是必然事件(LiteLLM 有过 `gpt-5.1-mini` 漏登记导致误拒的真实事故)。L8 用真实调用反向校验每条登记,是这张表的**过期告警**——模型升级后若 `can_disable` 声明失真,这里会先炸。建议纳入发版前清单定期执行。
|
||||||
|
|
||||||
|
## 10. 明确不做
|
||||||
|
|
||||||
|
不为中转的观测漂移在库内加任何机制(多轮取众数、渠道探测、重试到拿到 `reasoning_tokens`)——中转路由不受请求参数影响,探测结果不可迁移,属 YAGNI 违规;该问题在运维侧解决,写入 wiki 前提。
|
||||||
|
|
||||||
|
不改 `SourceConfig` 的公开字段形态:`enable_thinking` 保持 `bool | None`。分档需求走已有的 `extra_body` / `overlay`,两条路径已进缓存 key 与 `sampling` 遥测列,新增字段则要额外接这两处,是隐藏成本。
|
||||||
|
|
||||||
|
不动 qwen / deepseek 的 profile;不碰 `pricing.py`;不引入任何新依赖。
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
|
1. `ENABLE_THINKING=false` + MiniMax-M3 → 请求体含 `reasoning_effort: none`,响应 `reasoning_tokens is None`,真实 API 多轮验证
|
||||||
|
2. `ENABLE_THINKING=false` + MiniMax-M2.7 → **装配期报错**,文案说明该模型无法关闭推理
|
||||||
|
3. `ENABLE_THINKING` 任意非 `None` + `provider=openai` → **装配期报错**,指路 `register_provider` / `extra_body`
|
||||||
|
4. 未登记模型 + 任意 `enable_thinking` → 正常注入 + 一条 warning
|
||||||
|
5. `extra_body={"reasoning_effort":"high"}` 仍覆盖 profile 注入
|
||||||
|
6. 流式与非流式均能采到 `reasoning_tokens`;打捞路径记 `None` 而非 `0`
|
||||||
|
7. 改 `enable_thinking` → 缓存 key 变化;不配该项的存量 scope key 逐字不变
|
||||||
|
8. 遥测两后端新列可写、旧库经 ALTER 迁移后可写
|
||||||
|
9. e2e 报告存档于 `tests/outputs/e2e/`,矩阵覆盖情况可核
|
||||||
|
|
||||||
|
每条均需"先失败后通过"的证据(测试结果门)。
|
||||||
|
|
||||||
|
## 12. 影响与风险
|
||||||
|
|
||||||
|
**这是行为变更,不是纯修复。** MiniMax 源的 `ENABLE_THINKING` 从"无效"变为"生效",CHANGELOG 须醒目标注;dissect 会有一次性缓存冷启动。
|
||||||
|
|
||||||
|
**dissect 的 Phase-0 实验设计需调整。** M2.7 上做不了"开思考 vs 关思考"的对照——这是模型固有属性,任何库层改动都无法改变。可行替代是只在 M3 上做该对照,或将因子改为"高档 vs 低档"。此结论须同步给 dissect。
|
||||||
|
|
||||||
|
**能力表的正确性依赖实测,且经中转。** 三条 MiniMax 结论均在自建 new-api 中转下取得,直连官方端点未验证;表中每条 `evidence` 须写明这一点。若下游改为直连,L8 漂移哨兵是发现失真的第一道防线。
|
||||||
|
|
||||||
|
**新增两处失败面,其中一处会立刻打挂 dissect。**(2026-08-02 独立核验修正:本节初稿只列了 `openai` 那一处,遗漏了 M2.x。)其一是 `provider=openai` + 配了 `ENABLE_THINKING`,经全仓与 dissect 检索当前无此用法(dissect 的 K3 scope 用 `provider=openai` 但未配该项)。其二是**关不掉推理的模型 + `ENABLE_THINKING=false`**,而 `dissect/.env:80,85` 正是 `MiniMax-M2.7` + `false` —— 合并后该 scope 装配即抛 `ValueError`,实验链启动就挂。这是本设计的**预期行为**(给不了语义保证就必须说),但必须与 dissect 协调后再合并,不能突然打挂它。
|
||||||
|
|
||||||
|
**三个参考下游零破坏**:VT / CHS / GovDoc 的 thinking 用法均为二元,本方案不改公开字段形态。
|
||||||
|
|
||||||
|
## 13. 另立 issue(不在本次范围)
|
||||||
|
|
||||||
|
`kimi-k3` 拒绝 `temperature=0`(400),而 400 归 `RequestRejectedError` 不重试不换源,下游统一下发 `temperature=0` 会导致此类源 100% 硬失败。与本次两条 issue 同源(供应商能力差异未被建模),但属采样参数域,独立处理。
|
||||||
|
|
||||||
|
`qwen` 的 `strip_think_tags=True` 已过时(实测走 `reasoning_content`,正文无 `<think>` 标签),无害死代码,可顺带清理或另记。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:response-observability-fields
|
||||||
|
title: "响应可观测字段扩展(Issue #3)"
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 响应可观测字段扩展(Issue #3)
|
||||||
|
|
||||||
|
全文见 `2026-07-31-response-observability-fields-design.md`。来源: Gitea Issue #3(下游 dissect 的调用审计需求)。
|
||||||
|
|
||||||
|
## 选定方案
|
||||||
|
|
||||||
|
| 决策 | 选定 | 关键理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| A 采集路径 | `TransportResult` 追加 `cached_prompt_tokens` / `model_reported` 强类型字段,解析留在 `openai_compat.py` | OpenAI 报文格式知识不出 `transports/`,middleware 只做搬运(P7) |
|
||||||
|
| B 缓存命中语义 | 原样回放;度量口径必须带 `cache_hit = false` | 与 `_rehydrate` 既有口径一致——它只覆写时序字段,`model`/`prompt_tokens` 全回放 |
|
||||||
|
| C 缓存单价 | `ModelPrice` 加可选 `cached_input_per_1m`,`cost()` 加可选参 | 旧价格表与 `embedding.py:419` 三参调用零改动;未配置该档时**不猜折扣率**,退化为全额计价 |
|
||||||
|
| D 遥测扩列 | 端口 18 → 20 字段;DDL 加列 + 初始化期幂等补列 | `CREATE TABLE IF NOT EXISTS` 不会给旧库补列,INSERT 会**逐行 warning 丢弃**——遥测全失却无硬失败提示 |
|
||||||
|
|
||||||
|
## 被否决的备选
|
||||||
|
|
||||||
|
| 备选 | 否决原因 |
|
||||||
|
|---|---|
|
||||||
|
| A1 往 `raw` 里塞约定键 | `dict[str, Any]` 沦为隐式契约,且 middleware 要懂 OpenAI 嵌套结构 |
|
||||||
|
| A3 middleware 内解析 raw | 报文格式知识进 middleware,新增非 OpenAI 兼容 transport 时会分叉,违反分层 |
|
||||||
|
| B2 命中时置 None / B3 混合 | 与同层 `prompt_tokens` 的回放行为不一致,下游要记两套规则 |
|
||||||
|
| C2 `cost()` 直接收 `LLMResponse` | `pricing.py` 会反向依赖 `types.py`,且纯函数难单测 |
|
||||||
|
| D2 只改 DDL、文档写「删表重建」 | 已建表的开发机/下游只会看到降级 warning,排查成本高 |
|
||||||
|
| D3 引入 alembic 迁移框架 | 新增依赖违反「依赖极简」铁律,规模严重不匹配 |
|
||||||
|
|
||||||
|
## 独立审查修正(2026-07-31)
|
||||||
|
|
||||||
|
Codex CLI 安装损坏(vendor 二进制缺失),改由全新上下文的 Claude subagent 审。三条问题全部核实属实并已折回设计:
|
||||||
|
|
||||||
|
1. PG 缺列时**不是**结构性短路,而是逐行 warning(`_failed` 仅在 `_ensure_ready` 置位)。
|
||||||
|
2. SQLite 补列若塞进 `__init__` 现有 try,异常会让 `_conn` 停在 `None` → recorder 永久 no-op。已定纪律: 独立 try、置于 `self._conn = conn` 之后、duplicate column 视为成功。
|
||||||
|
3. 「端口无默认值 → 漏改即报错」不成立(无 mypy,8 个 fake 全是 `**fields`)。改为新增「emitter 实参键集合 == `_COLUMNS`」契约测试兜底——否则 `KeyError` 会被 `_record` 的 `except Exception` 吞成 warning,静默丢遥测。
|
||||||
|
|
||||||
|
相关: [[m1-core-design]]、[[est-tokens-decoupling]]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:sampling-params
|
||||||
|
title: "采样参数透传设计(issue #4)"
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 采样参数透传设计(issue #4)
|
||||||
|
|
||||||
|
正文: `2026-07-31-sampling-params-design.md`。状态: 待人类审批。
|
||||||
|
|
||||||
|
- **选定方案**: 两层入口——调用级 `chat(..., overlay=)` 供逐 rollout 变化的 `seed`,配置级 `SourceConfig.extra_body`(env 键 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`,JSON 串)供恒定的 `temperature=0`。优先级 **结构化注入 > 调用级 > 配置级** 由现有层序天然给出,不加新机制。
|
||||||
|
- **issue 未提但必须一并处理的四件事**: ① 采样参数进缓存 key(否则 5 个 seed 全命中同一缓存、标准差恒为 0,实验静默作废——「无缓存毒化」铁律);② 保护键黑名单 `{model, messages, stream, stream_options}` 与值可 JSON 序列化,均在构造期报错(覆盖它们会击穿流式看门狗、成本遥测与 TPM 结算;不可序列化的值会在 `CacheMW` 降级 try 之外抛裸 `TypeError`,一行遥测都没有);③ 采样参数入遥测(端口 20 → 21 字段,列名 `sampling`);④ 入参拷贝语义。
|
||||||
|
- **关键结构决策**: `ChatRequest` 增 `sampling` 快照字段作为**跨洋葱层恒定的读取点**。`request.overlay` 在 `StructuredMW` 内侧含 `response_format`、外侧不含,缓存 key 与三个遥测 emit 入口若各读各的层就会口径分叉。`sampling` 列语义定死为「调用方意图 ⊎ 生效源 `extra_body`」,**不含**结构化注入。
|
||||||
|
- **被否决备选及理由**: `chat()` 展开为 `temperature=`/`seed=` 具名参数(供应商私有参数无穷尽,等于永久追加签名,违「深模块窄接口」);配置级放装配层全局字典(采样参数与源强相关,会把无效键发给不认识它的源);overlay 不进 key 靠调用方传 `cache_salt`(把毒化防护责任推给调用方,漏传不报错——正是 issue 抱怨的失败形态);采样参数不入遥测由下游 run 快照自记(中间态数据不可追溯,且分两步要做两遍 DDL 迁移);缓存与遥测直接读 `request.overlay` 不加 `sampling` 字段(口径必分叉);`sampling` 记含 `response_format` 的完整合并结果(列名为采样参数,且数 KB schema 逐行落库无谓膨胀);给 embedding 加 `extra_body` 透传(embedding 无采样一说,装配期报错比静默无效更能指路);transport 层重复校验保护键(三入口已构造期收口,属 gold-plating)。
|
||||||
|
- **附带修正**: `providers.py` 的 `minimax`/`openai` 空 thinking profile 补后果说明(`enable_thinking=False` 对两者不产生效果,调用方以为关掉了实际没关);`_SOURCE_FIELDS` 跨 scope 共用导致 `EXTRA_BODY` 在 OCR/EMBED scope 静默无效,改为构造期**剥离 + warning**(2026-07-31 人类拍板由原「装配期 `ValueError`」改此档: 这两条路径无采样语义,不值得让下游装配起不来)。**剥离不可省**——不剥离则遥测会记录一个从未发出的参数(决策 D 的 merge 读 `source.extra_body`,而 `monkey_ocr` 只发 multipart、`embed` payload 硬编码),那是数据造假而非参数失效;在 emitter 内特判调用方身份则违「遥测调用点收敛单一 helper」铁律。
|
||||||
|
- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),改派全新上下文 subagent 两轮只读审查。首轮报 5 项必修(三个 emit 入口口径分叉、OCR/embedding 耦合、JSON 序列化缺口、注释归属写反、同步清单漏 4 处),逐条核实后全部采纳;次轮结论通过,其 5 条建议(承重不变式测试、`sampling` 类型定死、拷贝语义跟进、共用范围收窄、报错文案指路)亦已就地收进。
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-08-02-thinking-switch-and-reasoning-tokens
|
||||||
|
title: "推理开关与 reasoning_tokens: 供应商实测与业界做法"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关与 reasoning_tokens:供应商实测与业界做法
|
||||||
|
|
||||||
|
> 类型:findings(事实基础)|日期:2026-08-02|来源:issue #5 / #6 调研
|
||||||
|
> 本文只记录**已验证的事实与其证据**,设计取舍见 `designs/2026-08-02-thinking-capability-design.md`。
|
||||||
|
> 本文的价值不限于这两条 issue——「同一语义、形态因模型而异」是本库长期要面对的一类问题,此处的结论与方法可复用。
|
||||||
|
|
||||||
|
## 1. 实验环境与方法
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 端点 | 自建 new-api 中转(`newapi.iomgaa.online/v1`,OpenAI 兼容) |
|
||||||
|
| 参数 | `temperature=0`、`max_tokens=800`、非流式为主,流式单独验证 |
|
||||||
|
| 题目 | 固定一道鸡兔同笼题,要求"只输出两个数字" |
|
||||||
|
| 判据 | `usage.completion_tokens_details.reasoning_tokens`(**唯一可靠的判别量**,见 §2.5) |
|
||||||
|
| 旁证 | `prompt_tokens` 变化——注入生效的参数会改变模型侧模板,输入侧 token 数随之变化 |
|
||||||
|
|
||||||
|
**方法论要点(可复用)**:判断一个参数"是否被上游真正消费",`prompt_tokens` 比输出长度可靠得多。输出长度受采样影响、方差大;而输入侧 token 数在同一请求体下是确定的,一旦变化就说明服务端换了模板,即参数确实到达了模型。本次三条关键结论全部由这个旁证锁定。
|
||||||
|
|
||||||
|
## 2. MiniMax:真开关是 `reasoning_effort`
|
||||||
|
|
||||||
|
### 2.1 M3 参数矩阵(非流式)
|
||||||
|
|
||||||
|
| 注入参数 | prompt | completion | reasoning_tokens | 判定 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 默认(不传) | 194 | 4 | 无 ctd | 不推理 |
|
||||||
|
| `reasoning_effort=none` | 194 | 10 | 无 ctd | 不推理 |
|
||||||
|
| `reasoning_effort=minimal` | **207** | 129 | 123 | 推理 |
|
||||||
|
| `reasoning_effort=low` | **207** | 98 | 93 | 推理 |
|
||||||
|
| `reasoning_effort=medium` | **207** | 183 | 177 | 推理 |
|
||||||
|
| `reasoning_effort=high` | **207** | 158 | 142 | 推理 |
|
||||||
|
| `thinking={"type":"enabled"}` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `thinking={"type":"disabled"}` | 194 | 4 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `enable_thinking=true` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
| `enable_thinking=false` | 194 | 5 | 无 ctd | **被静默丢弃** |
|
||||||
|
|
||||||
|
`prompt_tokens` 194→207 的 13 token 差是硬证据:`reasoning_effort` 被消费时模型注入了推理指令;另四种写法 prompt 恒为 194,参数根本没到达模型。
|
||||||
|
|
||||||
|
### 2.2 `none` 是被识别的真值,不是被当非法值丢弃
|
||||||
|
|
||||||
|
这是一个必须排除的伪解释——若中转把不认识的值直接丢掉,`none` 的表现会与"不传"无异,我们就会误以为它生效。
|
||||||
|
|
||||||
|
反证实验:传乱码值 `reasoning_effort="xyzzy"` → 返回 200、prompt=207、reasoning_tokens=180。**未知值不但没被丢弃,反而开启了推理。** 既然无效值的行为是"开推理",而 `none` 的行为是"不推理",两者不同,`none` 就必然是被识别的枚举值。
|
||||||
|
|
||||||
|
对照组:完全未知的**键** `zzz_bogus_param=1` → prompt=194、无 ctd、无报错,确认未知**键**才会被静默吞掉。
|
||||||
|
|
||||||
|
### 2.3 M2.7 / M2.5 的推理关不掉
|
||||||
|
|
||||||
|
三种参数形态各 3 次,`completion_tokens` 全部落在推理区间:
|
||||||
|
|
||||||
|
| 模型 | 默认(基线) | `reasoning_effort=none` | `thinking:{disabled}` | `thinking:{adaptive}` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| MiniMax-M2.7 | 372/283/285 | 275/301/248 | 310/190/219 | 299/269/246 |
|
||||||
|
| MiniMax-M2.5 | 273/–/256 | 363/353/264 | 286/278/320 | 278/228/259 |
|
||||||
|
|
||||||
|
真关闭应为 5–10("23 12" 两个数字),实测无一接近。
|
||||||
|
|
||||||
|
**三个独立外部来源与实测完全吻合**:
|
||||||
|
|
||||||
|
| 来源 | M3 | M2.7 / M2.5 |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenRouter `/api/v1/models` 的 `reasoning` 描述符 | `mandatory: false` | **`mandatory: true`** |
|
||||||
|
| models.dev 的 `reasoning_options` | `[{"type":"toggle"}]`(二元可控) | `[]`(有推理但无控制手段) |
|
||||||
|
| MiniMax 官方仓库 issue #121 | — | "M2.7 不允许关闭思考",无官方回复 |
|
||||||
|
|
||||||
|
**结论:M2.x 的推理是模型固有属性,不是参数没找对。** 任何库层改动都无法让它关闭;唯一诚实的做法是如实报错。
|
||||||
|
|
||||||
|
### 2.4 M3 的稳定性
|
||||||
|
|
||||||
|
同一请求打 10 次,`(prompt_tokens, 是否上报 ctd)` 全部为 `(194, False)`,零跳变——`enable_thinking=False` 的修复可以建立在 M3 上。
|
||||||
|
|
||||||
|
### 2.5 输出长度不是有效判别量(2026-08-02 e2e 补测,各 15 轮)
|
||||||
|
|
||||||
|
初版判据用 `completion_tokens` 阈值区分推理开关,被自己的数据证伪:
|
||||||
|
|
||||||
|
| 档位 | `completion_tokens` 观测范围 | `reasoning_tokens` |
|
||||||
|
|---|---|---|
|
||||||
|
| 关闭(`reasoning_effort=none`) | 4 – **46** | 15/15 轮为 `None` |
|
||||||
|
| 开启(`medium`) | **13** – 186 | 15/15 轮 > 0 |
|
||||||
|
|
||||||
|
**两档的输出长度分布重叠**:关闭档偶尔到 46(模型没照做「只输出两个数字」,把解题过程写进了正文——那是正文不是推理);开启档最低到 13(medium 档想得少的轮次)。按长度阈值判,两个方向都会误判。
|
||||||
|
|
||||||
|
而 `reasoning_tokens` 在同一批 30 轮里干净分开。**这条对下游同样成立**:想判断某次调用是否发生了推理,只能看 `reasoning_tokens`,不能看输出长度。
|
||||||
|
|
||||||
|
另有一个不含魔数的确定性锚点:同一模型上关闭档的 `prompt_tokens` 严格小于开启档(实测 194 < 207),因为供应商在开启时向模板注入了推理指令。这是相对比较,供应商改模板也不会失效。
|
||||||
|
|
||||||
|
## 3. qwen / deepseek:现有 profile 正确
|
||||||
|
|
||||||
|
| 模型 | `enable_thinking=false` | `thinking:{disabled}` | `reasoning_effort=none` | 现有 profile |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| qwen3.7-plus | ✅ 关闭(compl 5) | ✅ 关闭 | ✅ 关闭 | `enable_thinking` — **正确** |
|
||||||
|
| deepseek-v4-pro | ❌ 无效(仍推理 198) | ✅ 关闭(compl 3) | ✅ 关闭 | `thinking:{type}` — **正确** |
|
||||||
|
|
||||||
|
两点附带事实:
|
||||||
|
|
||||||
|
- **`reasoning_effort=none` 在三家都有效**,但这很可能是中转做了参数归一化。**不可据此认为可以统一发一个参数**——下游若直连供应商官方端点,该假设大概率不成立。翻译表必须一家一行。
|
||||||
|
- **qwen 的 `strip_think_tags=True` 已过时**:实测 qwen 走 `reasoning_content` 字段,正文中无 `<think>` 标签。无害,但属于死代码。
|
||||||
|
- **非流式没有 400**:DashScope 系"`enable_thinking` 仅支持流式"的限制经中转不存在。直连时是否仍存在未验证。
|
||||||
|
|
||||||
|
## 4. new-api 中转的三个行为(会污染观测)
|
||||||
|
|
||||||
|
这一节对任何经中转做实测的场景都适用,值得单独记住。
|
||||||
|
|
||||||
|
**(a)不校验参数值。** `reasoning_effort="xyzzy"` 返回 200 并当作"开推理"处理。**意味着"靠上游报错兜底"的设计模式在此失效**——Bedrock 式的"最小交集 + 裸逃生口"在这里等于零保护。
|
||||||
|
|
||||||
|
**(b)静默丢弃未知键。** 默认路径是 struct round-trip(`ConvertRequest` 返回 struct 再 `json.Marshal`),未知键在第一次序列化就消失。new-api 有 per-channel 的 `pass_through_body_enabled` 开关可改变此行为。
|
||||||
|
|
||||||
|
**(c)上游不返回 usage 时用本地 tokenizer 补算并整体替换。** 补算出的 usage 只有三个标量,`completion_tokens_details` 为零值。这直接解释了实测中的双峰现象:
|
||||||
|
|
||||||
|
| 现象 | 解释 |
|
||||||
|
|---|---|
|
||||||
|
| 同一请求 10 次:`prompt=74` 者 6 次不上报 `reasoning_tokens`,`prompt=72` 者 4 次上报,从不交叉 | `74` = 本地估算值,`72` = 上游真值;补算路径吃掉了 ctd |
|
||||||
|
|
||||||
|
**这不是多渠道路由**(MiniMax 侧为单渠道单密钥),也不是配置错误,而是上游偶发不返回 usage 时的兜底逻辑。中转日志中的 `local_count_tokens` 标志可现场确认。
|
||||||
|
|
||||||
|
**对库的直接影响**:`reasoning_tokens` 缺失**不能**解释为"该源不上报这个字段",只能解释为"**本次调用未上报**"。下游若按前者建立统计口径会算错。
|
||||||
|
|
||||||
|
## 5. 业界如何建模"同一语义、形态因模型而异"
|
||||||
|
|
||||||
|
调研覆盖 LiteLLM、OpenRouter、models.dev、LangChain、Vercel AI SDK、AWS Bedrock Converse、Portkey、Helicone、LlamaIndex、new-api/one-api。
|
||||||
|
|
||||||
|
### 5.1 核心共识:形态按 provider,能力按 model
|
||||||
|
|
||||||
|
| 概念 | 变化频率 | 应归属层次 |
|
||||||
|
|---|---|---|
|
||||||
|
| **形态**:参数长什么样(`enable_thinking` / `thinking.type` / `reasoning_effort`) | 协议方言,一个供应商数年不变 | provider 级 |
|
||||||
|
| **能力**:能否关闭、有几档、默认开不开 | 模型属性,同一供应商每代都变 | **model 级** |
|
||||||
|
|
||||||
|
注册单位的分布很能说明问题:LiteLLM(2986 条目)、models.dev(5949 条)、LangChain、OpenRouter(细到 endpoint)、Helicone 全部下沉到 model 级;**仍停在 provider 级的只有 Portkey 与 LlamaIndex,而这两家恰是失败语义最差的两家(均静默丢弃)**。二者相关不是偶然:注册单位不够细,就只能靠"表里没有 = 不发"来兜底,而这正是静默失效的成因。
|
||||||
|
|
||||||
|
### 5.2 失败语义的四种谱系
|
||||||
|
|
||||||
|
| 语义 | 代表 | 适用前提 |
|
||||||
|
|---|---|---|
|
||||||
|
| 默认报错 + 可配置降级开关 | LiteLLM(`UnsupportedParamsError` + `drop_params`) | 有 model 级能力表可依据 |
|
||||||
|
| 软降级 + 显式 warning 通道 | Vercel AI SDK(丢弃参数并 push `warnings[]`) | 调用方愿意读 warning |
|
||||||
|
| 静默忽略 + 可选路由过滤 | OpenRouter(默认忽略;`require_parameters:true` 改为排除不支持的上游) | 网关自己拥有路由权 |
|
||||||
|
| 硬失败(透传给上游报错) | Bedrock(`inferenceConfig` 4 字段交集 + `additionalModelRequestFields` 裸透传) | **上游会诚实报错** |
|
||||||
|
|
||||||
|
**选型时先问"我的上游会不会诚实报错"**。若不会(如本项目的中转),最后一种直接出局,静默类也不能选。
|
||||||
|
|
||||||
|
### 5.3 表会过期,这是公理
|
||||||
|
|
||||||
|
LiteLLM 有过真实事故(issue #27351:`gpt-5.1-mini` 漏登记导致 `temperature` 被误拒)。它的应对是**两种相反极性**,值得直接借鉴:
|
||||||
|
|
||||||
|
- **opt-in 能力**(用错会 400 或悄悄花钱):未登记 → 视作不支持 → 拒绝
|
||||||
|
- **opt-out 能力**(多半支持,误拒代价大):未登记 → 放行 → 只有表里显式写 `false` 才拒
|
||||||
|
|
||||||
|
维护方式上,LiteLLM/models.dev 靠社区 PR + CI 校验,LangChain 靠"上游拉取 + 本地增补 + 代码生成"。**对内部库而言唯一现实的答案是:谁实测出来谁登记,登记必须附实测证据与日期。**
|
||||||
|
|
||||||
|
### 5.4 「布尔开关 → 多档旋钮」无语义共识
|
||||||
|
|
||||||
|
| 系统 | effort → 预算的换算 |
|
||||||
|
|---|---|
|
||||||
|
| LiteLLM | 一组 2 的幂(1024/2048/4096/8192/16384),全部可用环境变量覆盖;gemini 各型号还另有分叉 |
|
||||||
|
| OpenRouter | `max_tokens` 的百分比(≈80%/50%/20%) |
|
||||||
|
| Helicone | 一律 `max_tokens/2`,完全不看档位 |
|
||||||
|
| LangChain | 明确不保证跨 provider 可比 |
|
||||||
|
|
||||||
|
**唯一对齐的是"关"**:`none` / `disabled` / `thinking:{type:"disabled"}` / OpenRouter `effort:"none"` 语义一致。"开"那一端没有任何标准。
|
||||||
|
|
||||||
|
**工程共识只有一条:这个映射必须是可覆盖的常量,不是可推导的公式。** 业界所有人都在拍脑袋,区别只在拍完让不让调用方改。
|
||||||
|
|
||||||
|
### 5.5 Vercel AI SDK 的一处设计值得单记
|
||||||
|
|
||||||
|
它的推理档位枚举里有一个 `'provider-default'`,与 `'none'`(明确关闭)严格区分。这与本库 `enable_thinking` 的三态(`None` 不干预 / `True` / `False`)是同一思想——**"调用方不表态"必须是一个独立的值,不能与任何具体档位混同**。本库这一点原本就做对了,应保持。
|
||||||
|
|
||||||
|
## 6. 附带发现(不属本次范围,建议另立 issue)
|
||||||
|
|
||||||
|
**kimi-k3 拒绝 `temperature=0`**:返回 `400 invalid temperature: only 1 is supported`(另有渠道回 `only 0.6`)。本库把 400 归入 `RequestRejectedError`——不重试、不换源。若下游统一下发 `temperature=0`,此类源会 100% 硬失败。这与本次两条 issue 同源:**供应商能力差异未被建模**。
|
||||||
|
|
||||||
|
**中转渠道可用性会波动**:kimi 渠道在 429 后被中转下线,随后返回 `404 Model not supported by any channel`。任何依赖真实 API 的测试都必须容忍源不可用(跳过并给出明确原因),而不是失败。
|
||||||
|
|
||||||
|
## 7. 未能证实
|
||||||
|
|
||||||
|
1. **MiniMax 官方文档对 `reasoning_effort` 的一手定义**:官方文档站三次抓取均失败。M2.x 关不掉有三处佐证,但官方原文未取得。另有二手来源称 MiniMax 原生开关是 `thinking:{type:"adaptive"/"disabled"}`——**该说法已被本次实测证伪**(M2.7/M2.5 上两种写法均无效),但"中转是否对 `reasoning_effort` 做了改写"仍未排除。直连官方端点复测可彻底澄清。
|
||||||
|
2. **qwen 直连 DashScope 时非流式 `enable_thinking` 是否仍报 400**:仅验证了经中转的行为。
|
||||||
|
3. **new-api 走本地补算的确切触发条件**:读到了补算分支与 `local_count_tokens` 标记,未逐条比对所有渠道类型。双峰现象与该解释高度吻合,但未在日志中直接验证。
|
||||||
|
4. **能力表条目对非本次实测模型的正确性**:qwen / deepseek 只测了各一个型号,同系其他型号未验证。
|
||||||
|
|
||||||
|
## 8. 对后续开发的指导
|
||||||
|
|
||||||
|
1. **判定参数是否生效,优先看 `prompt_tokens` 而非输出长度**(§1)。
|
||||||
|
2. **排除"无效值被静默丢弃"必须做反证实验**:传一个乱码值,看它的行为是否与目标值不同(§2.2)。
|
||||||
|
3. **经中转做的任何实测都要标注"经中转,直连未验证"**,并写进注释(§3、§7)。
|
||||||
|
4. **新增供应商或模型前,先查 OpenRouter `/api/v1/models` 与 models.dev**——它们的登记与本次实测 100% 吻合,可作为低成本预判,但不可作为运行时依赖。
|
||||||
|
5. **能力表条目必须附实测证据与日期**;表过期是必然事件,退化路径与漂移检测要一起设计(§5.3)。
|
||||||
|
6. **`reasoning_tokens` 缺失只能记 `None`,绝不可记 `0`**(§4c)——"观测不到"与"没发生"是两件事。
|
||||||
|
7. **判断"是否发生了推理"只能看 `reasoning_tokens`,不能看输出长度**(§2.5)——两档的 `completion_tokens` 分布是重叠的,长度阈值两个方向都会误判。
|
||||||
@@ -110,6 +110,26 @@
|
|||||||
"id": "plan:est-tokens-decoupling",
|
"id": "plan:est-tokens-decoupling",
|
||||||
"label": "est_tokens 解耦实施计划",
|
"label": "est_tokens 解耦实施计划",
|
||||||
"type": "plan"
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:response-observability-fields",
|
||||||
|
"label": "响应可观测字段扩展(Issue #3)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:response-observability-fields",
|
||||||
|
"label": "响应可观测字段扩展实现计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:sampling-params",
|
||||||
|
"label": "采样参数透传设计(issue #4)",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:sampling-params-plan",
|
||||||
|
"label": "采样参数透传实现计划(issue #4)",
|
||||||
|
"type": "plan"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -189,6 +209,34 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
"evidence": "5 任务实现设计 §3.2 的 11 条改动项;任务排序经中间态破窗分析(先加能力→切调用点→三态生效→解绑约束)",
|
||||||
"added": "2026-07-30T09:39:26.442986+00:00"
|
"added": "2026-07-30T09:39:26.442986+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:response-observability-fields",
|
||||||
|
"target": "design:response-observability-fields",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "计划 T1-T7 逐条实现设计的 A2/B1/C1/D1 四个决策",
|
||||||
|
"added": "2026-07-31T11:10:03.872049+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:sampling-params-plan",
|
||||||
|
"target": "design:sampling-params",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "11 个任务逐条覆盖设计的决策 A-G 与 §5 的 14 条测试清单",
|
||||||
|
"added": "2026-07-31T16:59:35.657367+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "finding:2026-08-02-thinking-switch-and-reasoning-tokens",
|
||||||
|
"target": "design:2026-08-02-thinking-capability-design",
|
||||||
|
"relation": "supports",
|
||||||
|
"evidence": "供应商实测与业界调研为该设计的形态/能力分层与失败语义提供事实依据",
|
||||||
|
"added": "2026-08-02T09:38:57.033054+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-08-02-thinking-capability",
|
||||||
|
"target": "design:2026-08-02-thinking-capability-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "T1-T10 逐条实现设计的 D1-D6 六个决策与 §11 九条验收标准",
|
||||||
|
"added": "2026-08-02T09:49:48.126539+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+16
-5
@@ -1,8 +1,8 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-07-30 09:39 UTC
|
> 自动生成,更新时间:2026-08-02 10:55 UTC
|
||||||
|
|
||||||
## design (16)
|
## design (21)
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
- [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design`
|
||||||
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
|
- [2026-07-30-est-tokens-decoupling-design](designs/2026-07-30-est-tokens-decoupling-design.md) `design:2026-07-30-est-tokens-decoupling-design`
|
||||||
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design`
|
||||||
|
- [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design`
|
||||||
|
- [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design`
|
||||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
@@ -19,8 +21,11 @@
|
|||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||||
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
|
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||||
|
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||||
|
|
||||||
## finding (11)
|
## finding (12)
|
||||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||||
@@ -32,23 +37,29 @@
|
|||||||
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
- [M4 迁移验收(GovDoc+CHS)](findings/m4-acceptance.md) `finding:m4-acceptance`
|
||||||
- [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`
|
||||||
|
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||||
|
|
||||||
## plan (12)
|
## plan (17)
|
||||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||||
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
|
- [2026-07-21-m3-ocr-plan](plans/2026-07-21-m3-ocr-plan.md) `plan:2026-07-21-m3-ocr-plan`
|
||||||
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
|
- [2026-07-22-m4-migration-plan](plans/2026-07-22-m4-migration-plan.md) `plan:2026-07-22-m4-migration-plan`
|
||||||
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
- [2026-07-30-est-tokens-decoupling-plan](plans/2026-07-30-est-tokens-decoupling-plan.md) `plan:2026-07-30-est-tokens-decoupling-plan`
|
||||||
|
- [2026-07-31-response-observability-fields](plans/2026-07-31-response-observability-fields.md) `plan:2026-07-31-response-observability-fields`
|
||||||
|
- [2026-07-31-sampling-params](plans/2026-07-31-sampling-params.md) `plan:2026-07-31-sampling-params`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||||
|
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||||
|
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||||
|
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
- [表结构: llm_calls(遥测 18 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||||
|
|
||||||
## metric (2)
|
## metric (2)
|
||||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||||
|
|||||||
@@ -57,3 +57,30 @@
|
|||||||
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
|
- [2026-07-30 09:39 UTC] 新增 plan: est_tokens 解耦实施计划 (plan:est-tokens-decoupling)
|
||||||
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
|
- [2026-07-30 09:39 UTC] 新增边: plan:est-tokens-decoupling --implements--> design:est-tokens-decoupling
|
||||||
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
|
- [2026-07-30 09:39 UTC] 重建索引: 42 篇页面
|
||||||
|
- [2026-07-31 08:35 UTC] 新增 design: 响应可观测字段扩展(Issue #3) (design:response-observability-fields)
|
||||||
|
- [2026-07-31 08:37 UTC] 重建索引: 44 篇页面
|
||||||
|
- [2026-07-31 11:10 UTC] 新增 plan: 响应可观测字段扩展实现计划 (plan:response-observability-fields)
|
||||||
|
- [2026-07-31 11:10 UTC] 新增边: plan:response-observability-fields --implements--> design:response-observability-fields
|
||||||
|
- [2026-07-31 11:10 UTC] 重建索引: 46 篇页面
|
||||||
|
- [2026-07-31 11:11 UTC] 重建索引: 46 篇页面
|
||||||
|
- [2026-07-31 12:25 UTC] 重建索引: 46 篇页面
|
||||||
|
- [2026-07-31 15:57 UTC] 新增 design: 采样参数透传设计(issue #4) (design:sampling-params)
|
||||||
|
- [2026-07-31 15:57 UTC] 重建索引: 48 篇页面
|
||||||
|
- [2026-07-31 16:00 UTC] 重建索引: 48 篇页面
|
||||||
|
- [2026-07-31 16:31 UTC] 重建索引: 48 篇页面
|
||||||
|
- [2026-07-31 16:59 UTC] 新增 plan: 采样参数透传实现计划(issue #4) (plan:sampling-params-plan)
|
||||||
|
- [2026-07-31 16:59 UTC] 新增边: plan:sampling-params-plan --implements--> design:sampling-params
|
||||||
|
- [2026-07-31 16:59 UTC] 重建索引: 50 篇页面
|
||||||
|
- [2026-07-31 17:01 UTC] 重建索引: 50 篇页面
|
||||||
|
- [2026-08-01 01:58 UTC] 重建索引: 50 篇页面
|
||||||
|
- [2026-08-02 09:38 UTC] 重建索引: 52 篇页面
|
||||||
|
- [2026-08-02 09:38 UTC] 新增边: finding:2026-08-02-thinking-switch-and-reasoning-tokens --supports--> design:2026-08-02-thinking-capability-design
|
||||||
|
- [2026-08-02 09:38 UTC] 新增 finding: 推理开关与 reasoning_tokens 供应商实测与业界做法 (finding:2026-08-02-thinking-switch-and-reasoning-tokens)
|
||||||
|
- [2026-08-02 09:38 UTC] 新增 design: 推理开关能力建模与 reasoning_tokens 采集 issue #5+#6 (design:2026-08-02-thinking-capability-design)
|
||||||
|
- [2026-08-02 09:39 UTC] 重建 Query Pack: 29 字符
|
||||||
|
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 09:49 UTC] 新增边: plan:2026-08-02-thinking-capability --implements--> design:2026-08-02-thinking-capability-design
|
||||||
|
- [2026-08-02 09:49 UTC] 新增 plan: 推理开关能力建模与 reasoning_tokens 采集实施计划 (plan:2026-08-02-thinking-capability)
|
||||||
|
- [2026-08-02 09:49 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 10:55 UTC] 重建索引: 53 篇页面
|
||||||
|
- [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测)
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 实现计划: 响应可观测字段扩展(Issue #3)
|
||||||
|
|
||||||
|
- **目标**: 让 `LLMResponse` 与遥测表如实暴露「供应商 prompt cache 命中的输入 token 数」与「API 实际返回的模型版本串」。
|
||||||
|
- **方案概述**: 报文解析留在 `transports/`(新增两个强类型字段随 `TransportResult` 上浮),`RetryMW` 只搬运;遥测端口由 18 字段扩到 20 并给两个后端加幂等补列;`PricingTable` 增加可选缓存单价档消除 cost 高估。缓存命中行按既有口径原样回放。
|
||||||
|
- **依据设计**: `research-wiki/designs/2026-07-31-response-observability-fields-design.md`(2026-07-31 已获人类批准,决策 A2/B1/C1/D1)。
|
||||||
|
- **涉及技术**: Python 3.11 frozen dataclass、httpx SSE 解析、sqlite3、asyncpg、pytest。
|
||||||
|
- **保真校验**: 本计划**不涉及** `reference/` 参考实现迁移,保真校验不适用。但遥测后端属 ARCHITECTURE §1.4 资产,T5 明确约束「不得改变既有降级语义」。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责 | 本次改动 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 冻结公共类型 | `LLMResponse` / `TransportResult` 各 +2 字段;`cache_hit` docstring 消歧 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | OpenAI 兼容报文解析 | 防御解析 helper;SSE sink 采集 `model`;两处 `TransportResult` 构造填新字段 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 尝试循环 | `_build_response` 搬运两字段 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 响应缓存 | **零代码改动**(自动透传),仅补测试固化行为 |
|
||||||
|
| `src/polygateway/pricing.py` | 单价换算 | `ModelPrice` +可选档;`cost()` +可选参;`from_file` 校验 |
|
||||||
|
| `src/polygateway/ports.py` | 端口契约 | `TelemetryRecorder` 18 → 20 字段 |
|
||||||
|
| `src/polygateway/telemetry/{sqlite,postgres}.py` | 遥测后端 | DDL +2 列;`_COLUMNS` +2;初始化期幂等补列 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 遥测唯一调用点 | `_record` 与三个 `emit_*` 搬运两字段;cost 换算传入缓存 token |
|
||||||
|
|
||||||
|
字段定义(全库唯一权威,后续任务一律引用此处):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# LLMResponse 与 TransportResult 尾部,同名同类型同默认值
|
||||||
|
cached_prompt_tokens: int | None = None # 供应商 prompt cache 命中的输入 token;None = 该源未上报
|
||||||
|
model_reported: str | None = None # API 响应体的 model 字段;None = 未上报
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T1. 类型层加字段
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/types.py`
|
||||||
|
|
||||||
|
**行为**: 在 `LLMResponse` 尾部(`structured_data` 之后)与 `TransportResult` 尾部(`raw` 之后)各追加上面两个字段。`cache_hit` 的语义在 `LLMResponse` docstring 中写明是「**PolyGateway 自身响应缓存**命中,与供应商 prompt cache 无关,后者见 `cached_prompt_tokens`」。
|
||||||
|
|
||||||
|
**验收**: 前 11 个字段的顺序与名字一字不动;新字段有默认值,`LLMResponse(...)` 按前 11 位置参数构造仍成立;`TransportResult` 现有两处构造(`openai_compat.py:354/436`)不传新字段也能构造。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_types.py`): ① 不传新字段时两个类型的新字段均为 `None`;② 按位置构造 `LLMResponse` 的前 11 字段仍可用(迁移兼容承诺)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_types.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: add cached prompt tokens and reported model to response types`
|
||||||
|
|
||||||
|
## T2. transport 采集与防御解析
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/transports/openai_compat.py`
|
||||||
|
|
||||||
|
**行为**分三处:
|
||||||
|
|
||||||
|
1. 新增两个模块级防御 helper(网关返回一律不可信,解析失败**返回 None,不抛异常**):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _coerce_cached_tokens(usage: Any) -> int | None:
|
||||||
|
"""从 usage.prompt_tokens_details.cached_tokens 取非负整数;任何形态异常 → None。"""
|
||||||
|
|
||||||
|
def _coerce_model_reported(value: Any) -> str | None:
|
||||||
|
"""响应体 model 字段: 非空 str 才收,其余(含空串/非 str)→ None。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`_coerce_cached_tokens` 需容忍:`usage` 为 None、`prompt_tokens_details` 缺失或非 dict、`cached_tokens` 为 `bool`/`str`/负数/浮点。`bool` 必须排除(Python 中 `isinstance(True, int)` 为真)。**`0` 必须如实保留而非归 None**——真实零命中与未上报是两回事,这是 issue 的核心诉求。
|
||||||
|
|
||||||
|
2. 流式路径:`_sse_delta`(`:44-47`)当前只把 `usage` 旁路进 sink。补一条——chunk 里出现 `model` 时写 `usage_sink["model"]`(**首次写入即固定**,后续 chunk 不覆盖,避免末帧异常值污染)。`_stream_once` 的 `TransportResult` 构造(`:354`)填 `cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage"))`、`model_reported=_coerce_model_reported(sink.get("model"))`。
|
||||||
|
|
||||||
|
3. 非流式路径:`TransportResult` 构造(`:436`)填 `_coerce_cached_tokens(body.get("usage"))` 与 `_coerce_model_reported(body.get("model"))`。
|
||||||
|
|
||||||
|
**验收**: `raw` 的内容保持原样不动(新字段是独立格子,不是杂物袋的扩充);OCR 与 embedding 的解析路径一行不改。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_openai_compat.py`,用现有 fake 响应二次构造):
|
||||||
|
|
||||||
|
| 用例 | 期望 |
|
||||||
|
|---|---|
|
||||||
|
| 非流式 usage 含 `prompt_tokens_details.cached_tokens: 128` | `cached_prompt_tokens == 128` |
|
||||||
|
| 流式 usage 帧同上 | 同上 |
|
||||||
|
| 无 `prompt_tokens_details` / usage 帧缺失 | `None` |
|
||||||
|
| `cached_tokens` 为 `"abc"` / `-1` / `True` / `1.5` / `[]` / dict | `None`,且**不抛异常** |
|
||||||
|
| `cached_tokens` 为 `0` | `0`(真实零命中,**不得**归 None) |
|
||||||
|
| `prompt_tokens_details` 非 dict | `None` |
|
||||||
|
| 非流式 body 含 `model: "MiniMax-Text-01-250321"` | `model_reported` 为该串 |
|
||||||
|
| 流式首个含 model 的 chunk 后又出现不同 model | 取**首个** |
|
||||||
|
| body 无 `model` / `model` 为 `""` | `None` |
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_openai_compat.py -v` → PASS(新增用例先失败后通过)。
|
||||||
|
|
||||||
|
**提交**: `feat: collect provider cache tokens and reported model in transport`
|
||||||
|
|
||||||
|
## T3. RetryMW 搬运与缓存回放固化
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/middleware/retry.py`
|
||||||
|
|
||||||
|
**行为**: `_build_response`(`:419-438`)追加 `cached_prompt_tokens=result.cached_prompt_tokens`、`model_reported=result.model_reported`。`model=source.model` **保持不变**——别名仍是主字段,真实版本是旁证(设计非目标 2)。
|
||||||
|
|
||||||
|
`middleware/cache.py` **不改一行**:`_RESPONSE_FIELDS` 由 `dataclasses.fields(LLMResponse)` 动态生成(`:26`)、`_serialize` 用 `asdict`(`:139`),新字段自动进出;决策 B1 要求命中时原样回放,而 `_rehydrate` 的覆写清单(`:113-119`)本就不含新字段,零改动即是正确行为。本任务用测试把它钉死。
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- `tests/unit/test_retry.py`: transport 返回带两字段的 `TransportResult` → `chat()` 返回的 `LLMResponse` 上两字段一致;transport 未上报时为 `None`。
|
||||||
|
- `tests/unit/test_cache.py`: ① 带两字段的响应写入缓存再命中,回放值与原值相等且 `cache_hit=True`;② **旧格式兼容**——手工构造缺这两个键的缓存 JSON 塞进后端,命中后能正常 rehydrate 且两字段为 `None`(不得抛异常回源)。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_retry.py tests/unit/test_cache.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: carry the new observability fields through retry and cache`
|
||||||
|
|
||||||
|
## T4. 缓存读取单价
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/pricing.py`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ModelPrice: # 追加第三档,可选
|
||||||
|
cached_input_per_1m: float | None = None
|
||||||
|
|
||||||
|
def cost(self, model: str, prompt_tokens: int, completion_tokens: int,
|
||||||
|
cached_prompt_tokens: int | None = None) -> float | None:
|
||||||
|
```
|
||||||
|
|
||||||
|
换算规则(设计 §4):配了缓存档**且** `cached_prompt_tokens` 为正 → `(prompt - cached) × input + cached × cached_input`;否则全额按 `input`(现状,逐位不变)。`cached > prompt` 时按 `prompt` 夹取并 `logger.warning` 一次(沿用 `_warned` 的去重思路,按 model 去重,防日志风暴),**绝不产生负成本**。
|
||||||
|
|
||||||
|
`from_file` 的 fail-loud 扩展:条目出现 `cached_input_per_1m` 键时必须可转 float 且非负,否则 `ValueError`;不出现该键 = 合法(旧价格表零改动)。`__post_init__` 同步校验非负。
|
||||||
|
|
||||||
|
顺带订正 `PricingTable` docstring(`pricing.py:36`)那句「cost() 是全库唯一换算点(经 TelemetryEmitter)」——实际有 `TelemetryEmitter`(`middleware/telemetry.py:137`)与 `embedding.py:419` 两个调用点(设计 §6 行为审计已声明)。**只改这一行注释,不做任何结构重构**。
|
||||||
|
|
||||||
|
**验收**: `embedding.py:419` 的三参调用形态**一行不改**仍可用;未配缓存档时,任意输入下 `cost()` 结果与改前逐位相等。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_pricing.py`): ① 配缓存档 + 命中 → 成本严格低于全额且等于手算值;② 未配缓存档 + 命中 → 与不传该参数结果相等;③ `cached > prompt` → 结果等于全部按缓存价、非负、有 warning;④ `cached_prompt_tokens=None/0` → 全额;⑤ 三参旧调用签名可用;⑥ 价格表含 `cached_input_per_1m: -1` 或 `"x"` → `from_file` 抛 `ValueError`;⑦ 无该键的旧价格表照常加载。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_pricing.py -v` → PASS。
|
||||||
|
|
||||||
|
**提交**: `feat: support a cached input price tier in the pricing table`
|
||||||
|
|
||||||
|
## T5. 端口扩字段与后端补列
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `TelemetryRecorder.record_llm_call`(`ports.py:250-271`)在 `cost` 之后追加 `cached_prompt_tokens: int | None` 与 `model_reported: str | None`,**不设默认值**(设计 §5:库外无第三方实现者)。同步该 Protocol 的「18 字段冻结」docstring。
|
||||||
|
|
||||||
|
2. 两个后端的 `_DDL` 加列(SQLite `INTEGER`/`TEXT`;PG `INTEGER`/`TEXT`,均可空、无默认值)。**新列在 DDL 里必须放在 `created_at` 之后(即表的最末尾),不得插在 `cost` 之后**——旧表走 `ALTER TABLE ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前面,两条路径的物理列序就会分叉,而 `tests/integration/test_postgres_telemetry.py:117-128` 的 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该表是与真实批跑共享的表、**严禁 DROP/TRUNCATE**(文件头隔离纪律),分叉后没有合规修法。
|
||||||
|
|
||||||
|
`_COLUMNS` 在 `"cost"` 之后追加同名两项即可——`_INSERT` 是显式列名拼装(`sqlite.py:62-65`/`postgres.py:67-71`),`_COLUMNS` 只需与自身的 `row = tuple(...)` 自洽,**与 DDL 物理列序无关**。
|
||||||
|
|
||||||
|
3. **幂等补列**,按设计 D1 纪律执行:
|
||||||
|
|
||||||
|
- **SQLite**(`sqlite.py:74-84`):补列代码必须放在 `self._conn = conn` **之后**、用**独立 try**,且**首行必须守卫 `if self._conn is None: return`**——初始化 try 吞掉失败时 `self._conn` 仍是 `None`(局部 `conn` 甚至未绑定),无守卫的补列块会抛 `AttributeError`/`NameError`,这两者不被 `sqlite3.Error` 捕获,会直接逃出 `__init__`,打破「初始化失败静默降级」的对外契约(既有测试 `tests/unit/test_telemetry.py:132-135` `test_unwritable_path_degrades_silently` 会红)。守卫之后:`PRAGMA table_info(llm_calls)` 取现有列名集合,缺哪列补哪列;捕获 `sqlite3.Error` 时消息含 `duplicate column` 视为成功(多进程共库的 TOCTOU),其余记 warning。**绝不允许**因补列失败把 `self._conn` 置回 `None`——那会让整个 recorder 永久 no-op。
|
||||||
|
- **Postgres**(`postgres.py:_ensure_ready` 内、`_DDL` 执行之后):两条 `ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS ...`,共享既有 `_init_lock` 与 `except asyncio.CancelledError: raise` 结构。
|
||||||
|
|
||||||
|
**验收(降级语义不得改变)**: SQLite 侧 `except` 不得加宽(取消天然穿透);PG 侧 `CancelledError` 分支保持在最前;写入失败仍是逐行 warning 丢弃,不冒泡。
|
||||||
|
|
||||||
|
**必须同步改的测试(共 5 处)**:
|
||||||
|
|
||||||
|
| 位置 | 内容 | 漏改会怎样 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/unit/test_telemetry.py:76` 起 `_record_minimal` | 手写 18 键 dict | **红**(`KeyError`) |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:81-105` `_record_minimal` | 同上 | **红** |
|
||||||
|
| `tests/unit/test_telemetry.py:18-40` `_EXPECTED_COLUMNS` | 19 项列序断言(含 `created_at`) | **红**;新列追加到 `created_at` **之后** |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:22-41` `_EXPECTED_COLUMNS` | 同上 | **红**;同上 |
|
||||||
|
| `tests/unit/test_ports.py:96` `_DummyRecorder` | 唯一写全签名的 fake | **不会红**(它只被 `:131` 的 `isinstance` 使用,`runtime_checkable` Protocol 只校验方法名不校验签名),但仍应同步以免误导后来者 |
|
||||||
|
|
||||||
|
前四处是本次仅有的天然拦截点;端口加参数**不会**带来编译期保护(本仓无 mypy,其余 8 个 fake 全是 `**fields`)。
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- `tests/unit/test_telemetry.py`(SQLite):① 20 字段写入后可读回两个新列的值(含 `None`);② **旧表升级**——先用 18 列 DDL 手工建表,再实例化 `SQLiteRecorder`,写入成功且新列有值;③ **补列失败路径**(设计 §8 第 ③ 条,最危险的分支,不可用成功路径顶替)——构造一个 ALTER 必然失败的场景(把 `llm_calls` 建成同名 view,或注入在 ALTER 上抛 `sqlite3.OperationalError` 的连接),断言构造**不抛异常**、`recorder._conn` 仍非 `None`、后续 `record_llm_call` 不抛(降级为逐行 warning);④ 初始化路径不可写时仍静默降级(`test_unwritable_path_degrades_silently` 保持绿)。
|
||||||
|
- `tests/integration/test_postgres_telemetry.py`:① 20 字段写入 PG 并 `SELECT` 回读;② 18 列旧表经初始化后自动补列并写入成功。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py tests/unit/test_ports.py -v` → PASS;PG 部分 `conda run -n PolyGateway --no-capture-output pytest tests/integration/test_postgres_telemetry.py -v` → PASS。**PG/Redis 属共享后端,严禁与其他会话或钩子测试并跑**,起跑前确认无并发占用。
|
||||||
|
|
||||||
|
**提交**: **与 T6 合并为一次提交**,不得单独落地。理由:T5 落地后 emitter 仍只传 18 键,后端的 `row = tuple(fields[col] for col in _COLUMNS)` 会抛 `KeyError`,被 `_record` 的 `except Exception`(`middleware/telemetry.py:164-165`)吞成 warning → **该 commit 处于全量遥测静默丢失的状态**,且现有测试无一能捕获。提交信息见 T6。
|
||||||
|
|
||||||
|
## T6. Emitter 搬运与契约测试(与 T5 同一次提交)
|
||||||
|
|
||||||
|
- [ ] **改**: `src/polygateway/middleware/telemetry.py`
|
||||||
|
|
||||||
|
**行为**: `_record`(`:108-125`)新增两个参数并透传给 `record_llm_call`;三个入口各自提供取值——
|
||||||
|
|
||||||
|
| 入口 | `cached_prompt_tokens` | `model_reported` |
|
||||||
|
|---|---|---|
|
||||||
|
| `emit_attempt` | `response.cached_prompt_tokens if response else None` | 同左 |
|
||||||
|
| `emit_cache_hit` | `response.cached_prompt_tokens`(B1 原样回放) | 同左 |
|
||||||
|
| `emit_terminal_failure` | `None` | `None` |
|
||||||
|
|
||||||
|
cost 换算(`:137`)改为把 `cached_prompt_tokens` 传进 `self._pricing.cost(...)`。`cache_hit → 0.0` 与 `usage_source == "unavailable" → None` 两条短路的**先后顺序一字不动**(ARCHITECTURE §5.1 cost 口径不变式)。
|
||||||
|
|
||||||
|
**测试**(`tests/unit/test_telemetry.py`):
|
||||||
|
|
||||||
|
- **契约测试(不可省)**: 用记录 kwargs 的 fake recorder 跑一次 `emit_attempt`,断言 `set(kwargs) == set(sqlite._COLUMNS) == set(postgres._COLUMNS)`。理由:`row = tuple(fields[col] for col in _COLUMNS)` 位于两个后端 try 之外(`sqlite.py:90`/`postgres.py:121`),emitter 漏传字段会抛 `KeyError` 并被 `_record` 的 `except Exception` 吞成 warning → 静默丢遥测;现有 8 个 `**fields` 形态的 fake 一个都拦不住。
|
||||||
|
- 三个入口各记一行,断言新字段取值符合上表。
|
||||||
|
- cost 回归:配了缓存档且响应带 `cached_prompt_tokens` → 落库 cost 低于全额;缓存命中行 cost 仍为 `0.0`;`unavailable` 行仍为 `None`。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_telemetry.py -v` → PASS;随后 `make ci` 全绿(含 ruff 与 import-linter)。
|
||||||
|
|
||||||
|
**提交**(含 T5 全部改动): `feat: record the observability fields end to end through telemetry`
|
||||||
|
|
||||||
|
## T7. 文档同步与发版
|
||||||
|
|
||||||
|
- [ ] **改**: `research-wiki/ARCHITECTURE.md`、`CHANGELOG.md`、`.env.example`、`pyproject.toml`、`src/polygateway/__init__.py`、四处「18 字段冻结」措辞点、Gitea Wiki 站
|
||||||
|
|
||||||
|
**行为**:
|
||||||
|
|
||||||
|
1. `ARCHITECTURE.md`:§5.1 新增字段表补两行;**§7.8「必录字段」的行内清单**(`:452`)补两项——该文件不含字面「18 字段冻结」,§7.8 与 D8(`:202`)才是遥测字段的落点;§7.8 末条「`pricing.py` 维护 model →(input 单价, output 单价)表」同步第三档。补一条度量口径警示(与 cost 缺口同款):**统计供应商缓存命中率必须带 `WHERE cache_hit = false`**,否则缓存回放行会被重复计入。
|
||||||
|
2. 代码里的「18 字段/18 列」措辞共 **6 处**,全部订正(`grep -rn "18 字段\|18 列" src/ tests/` 可复核):`ports.py:248`、`middleware/telemetry.py:31`、`pricing.py:6`、`telemetry/sqlite.py:87`、`telemetry/postgres.py:9`(「18 列 schema 与 SQLite 版同名同序」)、`tests/unit/test_telemetry.py:1`。
|
||||||
|
3. `.env.example:56` 是仓内**唯一**的价格表格式说明(无独立模板文件),补 `cached_input_per_1m` 可选档与「不填即全额计价、库不猜折扣率」的说明。
|
||||||
|
4. 版本 bump `1.0.3` → `1.1.0`,**两处必须同步**(`pyproject.toml:7` 与 `src/polygateway/__init__.py:34`;`tests/unit/test_package.py:11` 会断言二者相等)。
|
||||||
|
5. `CHANGELOG.md` 顶部新增 `## 1.1.0` 段,沿用既有写法(先讲问题、再讲变更、点明下游要读什么):两个新字段的语义与 `None`/`0` 之别、`cache_hit` 与供应商 prompt cache 的区分、遥测表新增两列与自动补列、价格表可选缓存档、度量口径的 `cache_hit = false` 约束。
|
||||||
|
6. Gitea Wiki 站(需单独 `git clone https://gitea.iomgaa.online/iomgaa/PolyGateway.wiki.git`)按 `docs-convention.md` §2 清单同步:`参考-公共API`(LLMResponse 字段表)、`参考-配置键`(价格表格式)、`指南-遥测与成本`(新列与成本校正口径)、`Home.md` 版本号与安装命令、`_Sidebar.md` 如有结构变化。
|
||||||
|
|
||||||
|
**验收**: 版本 bump 的提交**不允许单独存在**(docs-convention §2 门),必须与 wiki/CHANGELOG 同步在同一次交付内。
|
||||||
|
|
||||||
|
**验证**: `conda run -n PolyGateway --no-capture-output pytest tests/unit/test_package.py -v` → PASS;`make ci` 全绿。
|
||||||
|
|
||||||
|
**提交**: `chore: release 1.1.0 with the response observability fields`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合并前门(逐条对应 CLAUDE.md §3)
|
||||||
|
|
||||||
|
- [ ] 每个行为变更都有「先失败后通过」的测试证据(T1-T6 各自的新增用例)。
|
||||||
|
- [ ] `make ci` 全绿(ruff + import-linter + pytest + 覆盖率)。
|
||||||
|
- [ ] 派**全新上下文**的 verifier subagent 独立验证(跨多文件,`verification-before-completion` 强制档)。
|
||||||
|
- [ ] 合并前整分支代码审查(`requesting-code-review`)。
|
||||||
|
- [ ] Gitea Issue #3 的关闭说明:两个字段的最终名字与语义、缓存命中行的回放口径、遥测新列与补列行为。
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# 实现计划: 采样参数透传(issue #4)
|
||||||
|
|
||||||
|
- **设计**: `research-wiki/designs/2026-07-31-sampling-params-design.md`(2026-07-31 人类批准)
|
||||||
|
- **分支**: `feat/issue-4-sampling-params`
|
||||||
|
- **目标**: 让下游能固定解码参数(`temperature`/`seed`/`max_tokens`),且不破坏缓存隔离与遥测诚实性。
|
||||||
|
- **方案概述**: `chat()` 增 keyword-only `overlay` 参数(调用级),`SourceConfig` 增 `extra_body` 字段(配置级)。`ChatRequest` 增 `sampling` 快照字段作为跨洋葱层恒定读取点,供缓存 key 与遥测消费。遥测端口 20 → 21 字段。
|
||||||
|
- **技术**: Python 3.11+,frozen dataclass,`MappingProxyType`,sqlite3 / asyncpg DDL 幂等补列。
|
||||||
|
|
||||||
|
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责变更 |
|
||||||
|
|---|---|
|
||||||
|
| `src/polygateway/types.py` | 新增 `validate_request_overlay()` 与 `merge_sampling()` 两个纯函数;`ChatRequest.sampling` 字段;`SourceConfig.extra_body` 字段与构造期校验 |
|
||||||
|
| `src/polygateway/client.py` | `chat()` 增 `overlay` 参数;`model_fingerprint` 计算纳入 `extra_body` |
|
||||||
|
| `src/polygateway/middleware/cache.py` | `build_cache_key()` 增 `sampling` 入参并纳入 key |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | `_build_payload` 在 thinking profile 之后、overlay 之前应用 `source.extra_body` |
|
||||||
|
| `src/polygateway/config.py` | `_SOURCE_FIELDS` 增 `EXTRA_BODY`;`_cast` 增 `json` 分支 |
|
||||||
|
| `src/polygateway/ports.py` | `TelemetryRecorder.record_llm_call` 增第 21 参 `sampling` |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 三个 emit 入口按设计表格产出 `sampling`;`_record` 透传 |
|
||||||
|
| `src/polygateway/telemetry/sqlite.py` | DDL / `_BACKFILL_COLUMNS` / `_COLUMNS` 增 `sampling` |
|
||||||
|
| `src/polygateway/telemetry/postgres.py` | DDL / `_BACKFILL` / `_COLUMNS` 增 `sampling` |
|
||||||
|
| `src/polygateway/ocr.py` / `embedding.py` | 构造期剥离 `extra_body` + warning(决策 G) |
|
||||||
|
| `src/polygateway/providers.py` | minimax/openai 空 thinking profile 补后果注释(决策 F) |
|
||||||
|
| `.env.example` / `README.md` / `CHANGELOG.md` / `research-wiki/ARCHITECTURE.md` | 文档同步(设计 §6) |
|
||||||
|
|
||||||
|
**各任务需新增的 import**(现状核实,不加即 NameError):
|
||||||
|
|
||||||
|
| 文件 | 需新增 |
|
||||||
|
|---|---|
|
||||||
|
| `types.py` | `from collections.abc import Mapping`、`from types import MappingProxyType`、`import json`。**该文件无 `from __future__ import annotations`**,注解在类体求值,`Mapping` 必须真导入 |
|
||||||
|
| `client.py` | `import json`、`import hashlib` |
|
||||||
|
| `config.py` | `import json` |
|
||||||
|
| `ocr.py` / `embedding.py` | `import dataclasses`(现只有 `from dataclasses import dataclass`)、`from loguru import logger`(若未导入) |
|
||||||
|
| `middleware/telemetry.py` | `merge_sampling`/`canonical_sampling_json` 需**运行时**导入(现对 `polygateway.types` 只在 `TYPE_CHECKING` 下导入) |
|
||||||
|
|
||||||
|
**关键接口**(跨任务消费,此处定死):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# types.py —— 两个纯函数 + 两个字段
|
||||||
|
_PROTECTED_OVERLAY_KEYS = frozenset({"model", "messages", "stream", "stream_options"})
|
||||||
|
|
||||||
|
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
||||||
|
"""校验采样参数覆盖层并返回浅拷贝;origin 用于错误信息定位来源。
|
||||||
|
|
||||||
|
保护键会击穿治理(model→成本算错、messages→缓存与遥测口径失真、
|
||||||
|
stream/stream_options→绕过看门狗与 usage 帧);值必须 JSON 可序列化,
|
||||||
|
否则会在 CacheMW 的降级 try 之外抛裸 TypeError(设计 §决策 B)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""合并配置级与调用级采样参数(调用级优先);两者皆空返回空 dict。"""
|
||||||
|
|
||||||
|
def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
||||||
|
"""遥测列与缓存 key 共用的序列化口径;空 mapping → None。"""
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatRequest:
|
||||||
|
...
|
||||||
|
overlay: dict[str, Any] = field(default_factory=dict)
|
||||||
|
sampling: Mapping[str, Any] = field(default_factory=dict) # 新增
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceConfig:
|
||||||
|
...
|
||||||
|
extra_body: Mapping[str, Any] = field(default_factory=dict) # 新增,__post_init__ 转 MappingProxyType
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# middleware/cache.py —— 签名扩展(sampling 为 keyword-only)
|
||||||
|
# 默认值用 None 而非 {}: dict 字面量作默认参数会被 ruff B006 拦下
|
||||||
|
def build_cache_key(
|
||||||
|
model_fingerprint: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
namespace: str,
|
||||||
|
salt: str | None,
|
||||||
|
*,
|
||||||
|
sampling: Mapping[str, Any] | None = None,
|
||||||
|
) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# client.py —— chat() 新签名
|
||||||
|
async def chat(
|
||||||
|
self, messages: list[dict[str, Any]], *,
|
||||||
|
session_id: str | None = None, parent_call_id: str | None = None,
|
||||||
|
cache_salt: str | None = None, cache_namespace: str | None = None,
|
||||||
|
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||||
|
stream: bool = True,
|
||||||
|
overlay: Mapping[str, Any] | None = None, # 新增
|
||||||
|
) -> LLMResponse: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 任务清单
|
||||||
|
|
||||||
|
任务按依赖排序;每个任务一次提交、独立可验证。每个任务合并前必须出示**先失败后通过**的测试证据(先写测试跑红,再实现跑绿)。
|
||||||
|
|
||||||
|
统一验证命令前缀:`conda run -n PolyGateway --no-capture-output pytest`。
|
||||||
|
|
||||||
|
> **共享后端纪律**: 涉及 Redis/Postgres 的 integration 测试严禁与其他会话并跑(含 git 钩子触发的测试)。Task 7、Task 11 受此约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 1: `types.py` 内核 —— 校验与合并纯函数 + 两个新字段
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/types.py`;测试 `tests/unit/test_types.py`
|
||||||
|
|
||||||
|
**实现行为**:
|
||||||
|
|
||||||
|
1. `validate_request_overlay(overlay, *, origin)`,**校验顺序即下列顺序**:
|
||||||
|
- 键必须是 `str`,否则 `ValueError`(canonical JSON 要求)。**必须排在序列化试探之前**——`{1: "a", "b": 2}` 在 `sort_keys=True` 下抛的是 `TypeError: '<' not supported between 'str' and 'int'`,若先试序列化会被误报成"值不可 JSON 序列化",指错方向;
|
||||||
|
- 命中 `_PROTECTED_OVERLAY_KEYS` 任一键 → `ValueError`,信息含 origin、违规键名、以及**为什么**(如 `stream` 会绕过流式看门狗);
|
||||||
|
- 对整个 mapping 做 `json.dumps(..., sort_keys=True)` 试序列化,`TypeError` → 转 `ValueError` 并指出该值不可 JSON 序列化(信息提示改用 `float(x)` 等原生类型);
|
||||||
|
- 返回 `dict(overlay)` 浅拷贝。
|
||||||
|
2. `merge_sampling(extra_body, sampling)` → `{**extra_body, **sampling}`(调用级优先)。
|
||||||
|
3. `canonical_sampling_json(merged)` → 空则 `None`,否则 `json.dumps(merged, sort_keys=True, ensure_ascii=False)`。
|
||||||
|
4. `ChatRequest` 增 `sampling` 字段(见 §1 关键接口)。
|
||||||
|
5. `SourceConfig` 增 `extra_body` 字段;`__post_init__` 新增 `_validate_extra_body()`:调 `validate_request_overlay(self.extra_body, origin=f"SourceConfig({self.name}).extra_body")`,再 `object.__setattr__(self, "extra_body", MappingProxyType(dict(...)))`(frozen dataclass 需用 `object.__setattr__`)。
|
||||||
|
|
||||||
|
**已知后果(必须显式接受,不是疏漏)**: `SourceConfig` 加 mapping 字段后**不再 hashable**(`hash()` → `TypeError`),且因 `MappingProxyType` 不可 pickle,`dataclasses.asdict()` / `copy.deepcopy()` 也会失败。
|
||||||
|
|
||||||
|
- 不可 hash 是**加任何 mapping 字段的固有代价**,与是否用 `MappingProxyType` 无关(裸 `dict` 同样不可 hash),无法规避;
|
||||||
|
- 库内当前无调用点会踩:`asdict` 只用于 `LLMResponse`/`EmbeddingResponse`(`cache.py:140`),全库无 `set(sources)` 或以源作 dict key 的写法;
|
||||||
|
- 保留 `MappingProxyType` 而非裸 dict,是因为决策 E 的只读约束值得这个代价;下游要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace(source, ...)`(已验证可行,会重跑 `__post_init__` 重新包 proxy,不递归)。
|
||||||
|
|
||||||
|
**验收标准**: 四个保护键各自触发 `ValueError` 且信息含原因;非 str 键报的是"键必须是 str"而非"不可序列化";`{"temperature": object()}` 类不可序列化值报 `ValueError` 而非 `TypeError`;合法 `{"temperature": 0, "seed": 42}` 通过并返回独立副本(改原 dict 不影响返回值);`SourceConfig.extra_body` 构造后为 `MappingProxyType` 且不可改。
|
||||||
|
|
||||||
|
**测试要求**: 新增 `tests/unit/test_types.py::TestSamplingValidation`,覆盖上述每条。不可序列化值用 `object()` 实例即可,不引入 numpy 依赖。**另加一条锁定测试**:`pytest.raises(TypeError): hash(source_config)`,把"不再 hashable"钉成有意行为——否则将来有人踩到时会以为是 bug 并"修"回去。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_types.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 2: `config.py` —— `EXTRA_BODY` env 解析
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/config.py`;测试 `tests/unit/test_config.py`
|
||||||
|
|
||||||
|
**实现行为**:
|
||||||
|
- `_SOURCE_FIELDS` 增 `"EXTRA_BODY": ("extra_body", "json")`;
|
||||||
|
- `_cast` 增 `json` 分支:`json.loads` 失败 → `ValueError`(沿用既有 `配置 {key} 解析失败: {exc}` 包装);解析结果**非 dict** → `ValueError`,信息说明必须是 JSON 对象(而非数组/标量)。
|
||||||
|
|
||||||
|
**验收标准**: `LLM__QWEN__1__EXTRA_BODY={"temperature":0}` → `SourceConfig.extra_body == {"temperature": 0}`;`{invalid` → `ValueError`;`[1,2]` → `ValueError`;`{"model":"x"}` → `ValueError`(经 Task 1 的 `SourceConfig.__post_init__` 保护键校验)。
|
||||||
|
|
||||||
|
**测试要求**: 新增 4 个 case 覆盖上述。**注意**: 这里同时验证了 Task 1 的校验确实挂在装配路径上。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_config.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 3: `chat()` 入口 + transport 应用 + fingerprint
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/client.py`、`src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_client.py`、`tests/unit/test_openai_compat.py`
|
||||||
|
|
||||||
|
**实现行为**:
|
||||||
|
|
||||||
|
1. `chat()` 增 `overlay` 参数(见 §1 签名)。进洋葱**之前**:
|
||||||
|
```python
|
||||||
|
validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||||
|
```
|
||||||
|
同一份 `validated` 对象同时填 `ChatRequest.overlay` 与 `.sampling`(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。
|
||||||
|
2. `_build_payload`:在 thinking profile 之后、`payload.update(overlay)` 之前插入 `payload.update(source.extra_body)`。**顺序即优先级,不可调换**。
|
||||||
|
3. `model_fingerprint`(`client.py:117`)改为:
|
||||||
|
```python
|
||||||
|
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||||
|
marks = sorted({json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False)
|
||||||
|
for s in sources if s.extra_body})
|
||||||
|
if marks:
|
||||||
|
fingerprint += "|" + hashlib.sha256("".join(marks).encode()).hexdigest()
|
||||||
|
```
|
||||||
|
全源 `extra_body` 皆空时字面量与旧实现**逐字相同**。`dict(...)` 是因为 `MappingProxyType` 不能直接进 `json.dumps`。
|
||||||
|
|
||||||
|
**验收标准**: 配置 `temperature=0` + 调用级 `temperature=1` → payload 中为 1;结构化注入的 `response_format` 覆盖调用级同名键;保护键在 `chat()` 入口即 `ValueError`(未进洋葱,可用 mock handler 断言未被调用);全源无 `extra_body` 时 fingerprint 与旧值逐字相同;有 `extra_body` 时不同;改源 `name` 不改变 fingerprint。
|
||||||
|
|
||||||
|
**测试要求**: 覆盖设计 §5 测试 #3、#4(chat 侧)、#5、#6(拷贝语义:调用方在 `chat()` 返回后修改自己的 dict,不影响已构造的 request)、#8(不可 JSON 序列化的值在 `chat()` 入口即 `ValueError`,断言洋葱 handler 未被调用)。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_client.py tests/unit/test_openai_compat.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 4: 缓存 key 纳入 `sampling`
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/middleware/cache.py`;测试 `tests/unit/test_cache.py`
|
||||||
|
|
||||||
|
**实现行为**:
|
||||||
|
- `build_cache_key` 增 keyword-only `sampling` 参数(见 §1 签名),非空时以 `"sampling"` 键并入 `key_obj`(**仅非空参与**,与 `salt` 的"仅非 None"不同——见设计决策 A 末段);
|
||||||
|
- `CacheMW.__call__` 传 `sampling=request.sampling`(**不是 `request.overlay`**——后者在此层虽尚未被结构化注入污染,但读 `sampling` 才是语义正确且不依赖层序巧合的写法)。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 同 messages、不同 `seed` → 两个不同 key,第二次 miss(**issue 场景的直接回归**);
|
||||||
|
- 空 `sampling` 时 key 与旧实现**逐字相同**——测试须先把旧实现的 key 值固化为常量再比对(现有 `tests/unit/test_cache.py:39-54` 只有相等/不等断言,无 golden hash 可依);
|
||||||
|
- 同 `sampling` 不同键序 → 同一 key(canonical 序列化)。
|
||||||
|
|
||||||
|
**测试要求**: 覆盖设计 §5 测试 #1、#2。golden hash 的取法:在改动前先运行一次现有 `build_cache_key` 打印结果,写死进测试。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_cache.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 5: 地基不变式回归(承重)
|
||||||
|
|
||||||
|
**文件**: 测试 `tests/unit/test_structured.py`(或就近的洋葱集成测试文件)
|
||||||
|
|
||||||
|
**实现行为**: 纯测试任务,不改产品代码。
|
||||||
|
|
||||||
|
落点:`tests/unit/test_structured.py` 里既有的 `ScriptedTerminal` 恰好站在 RetryMW 的位置(`client.py:91` 的 `terminal = RetryMW(...)`,StructuredMW 是最内中间件),扩写它即可,**无需搭全洋葱**。
|
||||||
|
|
||||||
|
断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后——
|
||||||
|
1. terminal 每次收到的 `request.sampling` 与**构造 `ChatRequest` 时传入的 `sampling`** 逐字相同;
|
||||||
|
2. 同一时刻 `request.overlay` **含** `response_format`(证明两者确实分叉,`sampling` 不是冗余字段)。
|
||||||
|
|
||||||
|
**为什么单列一个任务**: 决策 C 与 D 都建立在"`sampling` 跨层恒定"之上,而这条目前只靠"`dataclasses.replace` 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。
|
||||||
|
|
||||||
|
**验收标准**: 该测试在故意把 `structured.py` 的 `replace` 改成重建 `ChatRequest`(丢掉 `sampling`)时**必须变红**——实施时须实际验证这一点,否则测试是空的。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_structured.py -v` → 全 PASS,且上述"故意破坏"实验红过一次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`;测试 `tests/unit/test_telemetry.py`
|
||||||
|
|
||||||
|
**实现行为**:
|
||||||
|
|
||||||
|
1. `ports.TelemetryRecorder.record_llm_call` 增第 21 参 `sampling: str | None`(排在 `model_reported` 之后)。
|
||||||
|
2. `TelemetryEmitter._record` 增同名参数并透传给 recorder。
|
||||||
|
3. 三个入口按设计决策 D 的表格产出(**不含**结构化注入的 `response_format`):
|
||||||
|
|
||||||
|
| 入口 | `sampling` 取值 |
|
||||||
|
|---|---|
|
||||||
|
| `emit_attempt` | `canonical_sampling_json(merge_sampling(source.extra_body, request.sampling))` |
|
||||||
|
| `emit_cache_hit` | `canonical_sampling_json(request.sampling)` |
|
||||||
|
| `emit_terminal_failure` | `canonical_sampling_json(request.sampling)` |
|
||||||
|
|
||||||
|
后两者无 `source` 可言(由最外层 TelemetryMW 调用),与 `model`/`provider`/`source_name` 在终态行置空是同一先例。
|
||||||
|
|
||||||
|
**关键约束**: `sampling` 必须由 emitter **内部推导**,**不得**作为新必填参数由调用者传入——否则 `ocr.py:418` 与 `embedding.py:372` 立刻 TypeError。
|
||||||
|
|
||||||
|
**验收标准**: 三个入口各自的 `sampling` 值符合上表;`response_format` **三行都不出现**;`request.sampling` 与 `source.extra_body` 皆空时为 `None`。
|
||||||
|
|
||||||
|
**测试要求**: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 7: 两个遥测后端落列 + 幂等补列
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||||
|
|
||||||
|
**实现行为**(逐字沿用 issue #3 建立的套路):
|
||||||
|
|
||||||
|
- **sqlite.py**: DDL 在 `model_reported` **之后**加 `sampling TEXT`;`_BACKFILL_COLUMNS` 追加 `("sampling", "TEXT")`;`_COLUMNS` 末尾追加 `"sampling"`。
|
||||||
|
- **postgres.py**: DDL 同位置加 `sampling TEXT`;`_BACKFILL` 追加 `("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT")`;`_COLUMNS` 末尾追加。
|
||||||
|
- 两处 `record_llm_call(**fields)` 按 `_COLUMNS` 取值,**无需改动**。
|
||||||
|
|
||||||
|
**硬约束**: 新列必须排在 `created_at` **之后**(两文件既有注释已说明理由:旧表只能 ALTER 追加到末尾,新建库若插在前面,两条路径物理列序分叉)。补列一律**先探测缺列再 ALTER**;失败只逐行降级,**绝不置结构性失能标志**(postgres 的 `_failed`)。
|
||||||
|
|
||||||
|
**连带必改**(不改则直接红):
|
||||||
|
|
||||||
|
| 位置 | 改什么 | 不改的后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/unit/test_telemetry.py:78-102` 的 `_record_minimal()` | `fields` dict 加 `"sampling": None` | **两侧所有落库测试全红**:`sqlite.py:126` / `postgres.py:161` 的 `row = tuple(fields[col] for col in _COLUMNS)` **在 try 之外**,`_COLUMNS` 加列后抛裸 `KeyError: 'sampling'` 冒泡出 `record_llm_call` |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:88-109` 的 `_record_minimal()` | 同上 | 同上 |
|
||||||
|
| `tests/unit/test_telemetry.py:18` 的 `_EXPECTED_COLUMNS` | 追加 `"sampling"` | 列序断言红 |
|
||||||
|
| `tests/integration/test_postgres_telemetry.py:22` 的 `_EXPECTED_COLUMNS` | 追加(另见 `:210,231` 引用点) | 列序断言红 |
|
||||||
|
| `tests/unit/test_ports.py:95-119` 的 `_DummyRecorder.record_llm_call` | 显式 20 参签名同步为 21 | **不会红**(`runtime_checkable` 的 isinstance 只查方法存在不查签名),但会与端口脱节,顺带同步 |
|
||||||
|
| `sqlite.py:123` docstring、`test_telemetry.py:1` 文案 | "20 字段" → "21 字段" | 无功能影响,文案与事实脱节 |
|
||||||
|
|
||||||
|
**验收标准**: 新建库列序正确;对**已存在的 20 列旧表**能幂等补列且补后列序与新建库一致;重复初始化不报错;补列失败(模拟只有 INSERT 权限)时仅 warning、后续写入不被禁用。
|
||||||
|
|
||||||
|
**测试要求**: 覆盖设计 §5 测试 #11、#12。Postgres 部分是 integration,**须独占 PG `polygateway` 库时序,严禁并跑**。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- `pytest tests/unit/test_telemetry.py -v` → 全 PASS
|
||||||
|
- `pytest tests/integration/test_postgres_telemetry.py -v` → 全 PASS(确认无其他会话在用 PG)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/ocr.py`、`src/polygateway/embedding.py`;测试 `tests/unit/test_ocr_client.py`、`tests/unit/test_embedding.py`
|
||||||
|
|
||||||
|
**实现行为**: 两个 `__init__` 在既有校验块(`quota_full` 域校验附近)之后、`self._sources = list(sources)` 之前:
|
||||||
|
|
||||||
|
```python
|
||||||
|
stripped = []
|
||||||
|
for src in sources:
|
||||||
|
if src.extra_body:
|
||||||
|
logger.warning(
|
||||||
|
"{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略"
|
||||||
|
"(需要 dimensions 等参数请提 issue): {}",
|
||||||
|
<"embedding"|"OCR">, src.name, dict(src.extra_body),
|
||||||
|
)
|
||||||
|
src = dataclasses.replace(src, extra_body={})
|
||||||
|
stripped.append(src)
|
||||||
|
self._sources = stripped
|
||||||
|
```
|
||||||
|
|
||||||
|
**剥离不是顺手清理,是承重的**: 不剥离则 Task 6 的 `merge_sampling(source.extra_body, ...)` 会让遥测**记录一个从未发出的参数**——`monkey_ocr.py:225,247` 只发 multipart `files=`(根本没有 JSON body),`openai_compat.py:343` 的 embed payload 硬编码 `{"model","input"}`。那是数据造假而非参数失效。替代方案(emitter 内特判调用方身份)违「遥测调用点收敛单一 helper」铁律,已否决。
|
||||||
|
|
||||||
|
**验收标准**: 带 `extra_body` 的源 → 装配**成功**(不抛异常)、记一条 warning、`client._sources` 上 `extra_body` 为空;该路径遥测 `sampling` 列为 `None`;不带 `extra_body` 时无 warning。
|
||||||
|
|
||||||
|
**测试要求**: 覆盖设计 §5 测试 #10。**后半段(遥测 `sampling` 为 None)是防遥测造假的真正断言,不可省**——只断言"装配成功 + 有 warning"是不够的。用 `caplog`/loguru 捕获断言 warning 存在。
|
||||||
|
|
||||||
|
**验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → 全 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 9: 决策 F —— 空 thinking profile 的后果注释
|
||||||
|
|
||||||
|
**文件**: 改 `src/polygateway/providers.py`
|
||||||
|
|
||||||
|
**实现行为**: 给 `openai`(`:46-51`)与 `minimax`(`:53-58`)两个 profile 各补一句**后果**说明:`enable_thinking=False` 对本 provider 不产生任何效果,需要关闭推理请用 `SourceConfig.extra_body`。
|
||||||
|
|
||||||
|
**注意**: `:52` 那条既有注释(「OpenAI 兼容基线,无已知注入差异」)在词法上属于紧随其后的 **minimax** 条目,`openai` 条目**没有**任何注释。补的是"后果"而非重复"为何为空"——不要写出与既有注释重复或矛盾的内容。
|
||||||
|
|
||||||
|
**验收标准**: 两个 profile 都能让读者明白 `enable_thinking=False` 对它们无效。纯注释变更,无行为变化。
|
||||||
|
|
||||||
|
**测试要求**: 无(纯注释)。此任务不单独提交,与 Task 10 合并提交。
|
||||||
|
|
||||||
|
**验证**: `make lint` → PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 10: 文档同步(设计 §6 清单)
|
||||||
|
|
||||||
|
**文件**: 改 `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
| 目标 | 具体改动 |
|
||||||
|
|---|---|
|
||||||
|
| `.env.example` | 在 `LLM__QWEN__1__TRUST_ENV` 注释行(`:20`)后加 `# LLM__QWEN__1__EXTRA_BODY={"temperature":0}` 及说明(JSON 对象串;保护键会报错;OCR/EMBED scope 会被忽略并 warning)。`client.py:251` docstring 声明本文件是键名清单事实源,漏写等于新键无处可查 |
|
||||||
|
| `README.md:83` | 该行逐一列举 `chat()` 关键字参数,补 `overlay` 及一句用途 |
|
||||||
|
| ARCH §5.2 | `chat()` 签名定稿段追加 `overlay` 要点(带默认值的 keyword-only,不破坏"调用点零改动"承诺) |
|
||||||
|
| ARCH §7.5 | key 公式补 `sampling` 项 + 两条已知副作用(seed 进 key 导致该路径必 miss;`model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中) |
|
||||||
|
| ARCH §7.7(`:451`) | 该节逐字段枚举 `SourceConfig` 构成(`name/provider/.../enable_thinking`),补 `extra_body` |
|
||||||
|
| ARCH §7.8(`:463`) | 必录字段 20 → 21,补 `sampling` 及其列语义(不含 `response_format`) |
|
||||||
|
| ARCH §9(`:519-527`) | 配置面键族事实源,登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY` |
|
||||||
|
| `CHANGELOG.md` | 公共 API 新增(`chat(overlay=)`、`SourceConfig.extra_body`)+ 遥测端口扩列 |
|
||||||
|
|
||||||
|
**Gitea Wiki 同步**(`docs-convention.md` §2,CLAUDE.md §6 标为硬门)。本次同时命中该表两行:
|
||||||
|
|
||||||
|
| 命中行 | 必同步页 |
|
||||||
|
|---|---|
|
||||||
|
| 新公共 API / 新能力 | 对应指南页(新增「固定解码参数」内容,落在 `指南-遥测与成本` 或新页)+ `参考-公共API`(`chat()` 签名、`SourceConfig.extra_body`)+ `_Sidebar.md` + CHANGELOG |
|
||||||
|
| 新增配置键 | `参考-配置键`(登记 `{SCOPE}__{PROVIDER}__{N}__EXTRA_BODY`)+ 相关指南页的配置片段 + `.env.example` |
|
||||||
|
|
||||||
|
指南页必须写明三条坑:① `seed` 逐次变化时该路径缓存**必 miss**;② `model_fingerprint` 是集合级指纹,同 scope 各源 `extra_body` 不同时仍可能跨源命中(要逐源可复现需每源独享 scope 或 namespace);③ OCR/EMBED scope 的 `EXTRA_BODY` 会被忽略并 warning。
|
||||||
|
|
||||||
|
**验收标准**: 每条都能在文件中指到具体位置;ARCH 的改动与设计文档不矛盾;wiki 两行清单逐页落实。
|
||||||
|
|
||||||
|
**测试要求**: 无(纯文档)。与 Task 9 合并提交。
|
||||||
|
|
||||||
|
**验证**: `make lint` → PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### - [ ] Task 11: 全链路集成验证与合并前检查
|
||||||
|
|
||||||
|
**文件**: 测试 `tests/integration/`(就近文件或新增)
|
||||||
|
|
||||||
|
**实现行为**: 端到端断言采样参数经 `chat()` → 选源 → transport payload 到达请求体(设计 §5 测试 #13),用 fake HTTP 层捕获实际 payload。
|
||||||
|
|
||||||
|
**合并前门(逐条出示证据)**:
|
||||||
|
1. `make ci` → 全绿(`make lint` + `make test` + 覆盖率)
|
||||||
|
2. import-linter 契约无新违规(校验函数落最内层 `types.py`,分层关系不变)
|
||||||
|
3. 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名
|
||||||
|
4. 派**全新上下文** verifier subagent 独立验证(CLAUDE.md §3.2 里程碑级/跨多文件硬门)
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- `make ci` → 全 PASS(**不要**在外面套 `conda run`:`Makefile` 每条 target 内部已是 `conda run -n PolyGateway ...`,嵌套会让内层输出被缓冲)
|
||||||
|
- verifier 报告无 blocking 问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 提交节奏
|
||||||
|
|
||||||
|
| 提交 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Task 1(types 内核) |
|
||||||
|
| 2 | Task 2(env 解析) |
|
||||||
|
| 3 | Task 3(chat 入口 + transport + fingerprint) |
|
||||||
|
| 4 | Task 4(缓存 key) |
|
||||||
|
| 5 | Task 5(地基不变式测试) |
|
||||||
|
| 6 | Task 6(遥测三入口) |
|
||||||
|
| 7 | Task 7(两后端落列) |
|
||||||
|
| 8 | Task 8(决策 G) |
|
||||||
|
| 9 | Task 9 + 10(注释与文档) |
|
||||||
|
| 10 | Task 11(集成验证,如有修补) |
|
||||||
|
|
||||||
|
每次提交调 `commit` skill。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中"issue 未提但必须处理"的部分,**不可跳过**。
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-08-02-thinking-capability
|
||||||
|
title: "推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)"
|
||||||
|
date: 2026-08-02
|
||||||
|
---
|
||||||
|
|
||||||
|
# 推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)
|
||||||
|
|
||||||
|
**目标**:让 `enable_thinking` 对每个源要么真实生效、要么显式报错,并采集 `reasoning_tokens` 以区分推理开销与生成开销。
|
||||||
|
|
||||||
|
**方案概述**:`ProviderProfile` 保留为「形态层」(参数长什么样,按 provider),新增 model 级「能力层」声明该模型能否关闭推理;两层在单一判定函数 `resolve_thinking` 相遇,装配期与请求期共用。同时照搬 issue #3 的 `_coerce_cached_tokens` 采集 `reasoning_tokens`,并把 `enable_thinking` 纳入缓存指纹。
|
||||||
|
|
||||||
|
**涉及技术**:Python 3.11 frozen dataclass、`MappingProxyType` 只读注册表、httpx、SQLite/PostgreSQL DDL 迁移、pytest。
|
||||||
|
|
||||||
|
**依据文档**:设计 `designs/2026-08-02-thinking-capability-design.md`;事实基础 `findings/2026-08-02-thinking-switch-and-reasoning-tokens.md`。
|
||||||
|
|
||||||
|
**保真校验**:本计划不涉及 `reference/` 参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/polygateway/types.py` | 修改 | `LLMResponse` / `TransportResult` 尾部各加 `reasoning_tokens` |
|
||||||
|
| `src/polygateway/providers.py` | 修改 | 形态层放宽为 `dict \| None`;新增能力层与 `resolve_thinking` |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 修改 | 采集 `reasoning_tokens`;`_build_payload` 接入 `resolve_thinking`;收 `capabilities` |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 修改 | `_build_response` 透传 `reasoning_tokens` |
|
||||||
|
| `src/polygateway/ports.py` | 修改 | `record_llm_call` 21 → 22 字段 |
|
||||||
|
| `src/polygateway/telemetry/sqlite.py` | 修改 | 建表列 + `_BACKFILL_COLUMNS` + `_COLUMNS`(新列排末尾) |
|
||||||
|
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上 |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` + 三个 `emit_*` 入口 |
|
||||||
|
| `src/polygateway/client.py` | 修改 | `capabilities` 参数贯通;装配守卫;缓存指纹纳入 `enable_thinking` |
|
||||||
|
| `tests/e2e/test_thinking_live.py` | 新建 | 真实 API 矩阵 L1–L9 |
|
||||||
|
| `CHANGELOG.md` / `research-wiki/schemas/llm-calls.md` | 修改 | 行为变更说明与字段表 21 → 22 |
|
||||||
|
|
||||||
|
**任务顺序不可调换**:T1–T3 先把 `reasoning_tokens` 打通(#6 是 #5 的验收仪器),T4–T7 再改推理开关,T8 用真实 API 验证,T9 收尾文档。
|
||||||
|
|
||||||
|
## 关键接口(跨任务消费,此处给出实际代码)
|
||||||
|
|
||||||
|
`providers.py` 新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个具体模型的推理能力(model 级);登记必须附实测证据与日期。"""
|
||||||
|
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability(
|
||||||
|
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||||
|
) -> ThinkingCapability | None:
|
||||||
|
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定退化)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def register_capability(
|
||||||
|
model: str,
|
||||||
|
capability: ThinkingCapability,
|
||||||
|
*,
|
||||||
|
base: Mapping[str, ThinkingCapability] | None = None,
|
||||||
|
) -> dict[str, ThinkingCapability]:
|
||||||
|
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 注入片段;不可满足时 ValueError(调用点翻译为领域错误)。
|
||||||
|
|
||||||
|
model 只用于错误与告警文案: 报错必须能定位到具体模型才有可操作性,
|
||||||
|
而 capability 为 None(未登记)时无从从别处取得模型名。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolve_thinking` 的判定顺序(**顺序即语义,不可调换**):
|
||||||
|
|
||||||
|
| 步 | 条件 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `enable_thinking is None` | 返回 `{}`(不干预) |
|
||||||
|
| 2 | 对应档 `slot is None` | `ValueError`:形态未知,指路 `register_provider` / `extra_body` |
|
||||||
|
| 3 | `capability is None` | `loguru.warning` 后返回 `slot`(能力未登记,从宽放行) |
|
||||||
|
| 4 | `enable_thinking is False` 且 `capability.can_disable is False` | `ValueError`:该模型无法关闭推理 |
|
||||||
|
| 5 | 其余 | 返回 `slot` |
|
||||||
|
|
||||||
|
第 2 步必须先于第 4 步:形态未知时无从注入,能力如何无关紧要。第 3 步先于第 4 步:未登记模型无 `can_disable` 可读。
|
||||||
|
|
||||||
|
`transports/openai_compat.py` 新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _coerce_reasoning_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### T1 — `reasoning_tokens` 进入类型与采集路径
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/types.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`;改测 `tests/unit/test_types.py`、`tests/unit/test_openai_compat.py`、`tests/unit/test_retry.py`
|
||||||
|
|
||||||
|
**行为**:`LLMResponse` 与 `TransportResult` **尾部**各加 `reasoning_tokens: int | None = None`(字段顺序是公共承诺,见 `types.py:1-5`,只增不删不改名)。新增 `_coerce_reasoning_tokens`,语义与 `_coerce_cached_tokens`(`openai_compat.py:161-177`)逐条对齐:非 `dict` 返回 `None`;`completion_tokens_details` 非 `dict` 返回 `None`;`bool` 显式排除(`isinstance(True, int)` 为真,放行会把 `True` 记成 1);负数返回 `None`;`0` 如实保留。流式(`:401` 附近)取 `sink.get("usage")`、非流式(`:485` 附近)取 `body.get("usage")`,与 `cached_prompt_tokens` 同处填值。`retry.py:_build_response` 透传。
|
||||||
|
|
||||||
|
**docstring 措辞**(必须逐字,理由见 findings §4c):`None` = **本次调用**未上报,**不可**写「该源未上报」——中转在上游不返回 usage 时会本地补算并吃掉该字段。
|
||||||
|
|
||||||
|
**验收**:非流式与流式响应含 `completion_tokens_details.reasoning_tokens: 7` → `reasoning_tokens == 7`;该键为 `0` → `0`(不与 `None` 混同);`completion_tokens_details` 缺失 / 非 dict / 值为 `True` / 值为 `-1` → 均为 `None`;`missing_done="salvage"` 打捞路径(无 usage 帧)→ `None` 而非 `0`。
|
||||||
|
|
||||||
|
**测试证据**:先加断言 → 失败(字段不存在)→ 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_openai_compat.py tests/unit/test_retry.py -v` → 全部 PASS。
|
||||||
|
|
||||||
|
### T2 — 遥测端口 21 → 22 字段与两后端落库
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`src/polygateway/middleware/telemetry.py`;改测 `tests/unit/test_ports.py`、`tests/unit/test_telemetry.py`、`tests/integration/test_postgres_telemetry.py`
|
||||||
|
|
||||||
|
**行为**:`record_llm_call` 在 `sampling` 之后追加 `reasoning_tokens: int | None`(不设默认值——库外无第三方实现者,见 `ports.py:250` 注释)。两个后端在建表 DDL、`_BACKFILL_COLUMNS`(sqlite)/ 迁移语句列表(postgres)、`_COLUMNS` 三处各加一项,**新列必须排在末尾**(两文件均有明文注释:旧表只能 ALTER 追加,新建库若插在前面会与迁移路径的物理列序分叉)。`middleware/telemetry.py` 的 `_record` 加参数,三个 `emit_*` 入口按 `cached_prompt_tokens` 的既有形态填值:`emit_attempt` 用 `response.reasoning_tokens if response else None`,`emit_cache_hit` 原样回放,`emit_terminal_failure` 填 `None`。
|
||||||
|
|
||||||
|
**不改 `pricing.py`**:推理 token 已含在 `completion_tokens` 内,单列计价即重复计费。
|
||||||
|
|
||||||
|
**验收**:新建库与经 ALTER 迁移的旧库物理列序一致;`reasoning_tokens=7` / `0` / `None` 三种值各自如实落库(`0` 与 `NULL` 可区分);遥测写失败仍降级为 warning 不冒泡。
|
||||||
|
|
||||||
|
**测试证据**:先扩字段清单断言 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS 或按既有约定 SKIP(无 PG 凭据时)。
|
||||||
|
|
||||||
|
### T3 — 提交点:issue #6 完整可用
|
||||||
|
|
||||||
|
- [ ] 运行 `conda run -n PolyGateway make ci`,确认全绿后提交。提交信息类型 `feat`,正文说明 `LLMResponse` 新增字段与遥测端口 21 → 22。此处独立成一个提交,便于 #6 单独回滚。
|
||||||
|
|
||||||
|
**测试证据**:本任务不引入新行为,证据即 T1 与 T2 各自的「先失败后通过」记录;提交前需确认这两组记录都已产生,不得以 `make ci` 全绿代替。
|
||||||
|
|
||||||
|
### T4 — 形态层放宽与 profile 修正
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||||
|
|
||||||
|
**行为**:`ProviderProfile.thinking_on` / `thinking_off` 类型由 `dict[str, Any]` 改为 `Mapping[str, Any] | None`。三值语义写进类 docstring:`{...}` = 已知注入片段;`{}` = 已知无需注入即处于该档;`None` = **未知**(库不知道该 provider 如何表达)。删除现有 docstring 里「两档皆空 ⇒ 不产生任何效果」那段(`providers.py:23-25` 与 `:50-51`)——它正是把「不支持」与「未知」编码成同一个值的根因。
|
||||||
|
|
||||||
|
`minimax` 填 `thinking_on={"reasoning_effort": "medium"}`、`thinking_off={"reasoning_effort": "none"}`;`openai` 两档改 `None`。qwen / deepseek **不动**(实测正确)。两处均加注释写明:取值依据 2026-08-02 经自建 new-api 中转的实测,直连官方端点未验证。
|
||||||
|
|
||||||
|
**验收**:`get_provider("minimax").thinking_off == {"reasoning_effort": "none"}`;`get_provider("openai").thinking_on is None`;qwen / deepseek 两档与改动前逐字相同。
|
||||||
|
|
||||||
|
**测试证据**:`tests/unit/test_providers.py:29,34` 现有断言锁的是空字典,先改成新期望 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||||
|
|
||||||
|
### T5 — 能力层与 `resolve_thinking`
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/providers.py`;改测 `tests/unit/test_providers.py`
|
||||||
|
|
||||||
|
**行为**:按「关键接口」一节的签名实现 `ThinkingCapability`、`DEFAULT_CAPABILITIES`、`get_capability`、`register_capability`、`resolve_thinking`。注册表用 `MappingProxyType` 只读,注册走纯函数返回新表(不修改共享状态,纯 asyncio 中立铁律),与既有 `register_provider`(`providers.py:84-90`)同形。
|
||||||
|
|
||||||
|
`DEFAULT_CAPABILITIES` 首发五条,`evidence` 逐条写明实测日期与样本量:
|
||||||
|
|
||||||
|
| 键 | `can_disable` | `evidence` 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MiniMax-M3` | `True` | 2026-08-02 实测 N=10,`reasoning_effort=none` 稳定关闭 |
|
||||||
|
| `MiniMax-M2.7` | `False` | 三形态各 N=3 全无效;OpenRouter 登记 `mandatory:true` |
|
||||||
|
| `MiniMax-M2.5` | `False` | 同上 |
|
||||||
|
| `qwen3.7-plus` | `True` | 实测 `enable_thinking=false` 关闭 |
|
||||||
|
| `deepseek-v4-pro` | `True` | 实测 `thinking:{"type":"disabled"}` 关闭 |
|
||||||
|
|
||||||
|
**验收**:`resolve_thinking` 五条判定各有一例;未登记模型返回 `slot` 并产生一条 warning(**loguru 不经标准 logging,pytest 的 `caplog` 抓不到**——必须复用项目既有写法 `logger.add(messages.append, level="WARNING")`,见 `tests/unit/test_config.py:29-31`);`enable_thinking=False` + `MiniMax-M2.7` 抛 `ValueError` 且消息含模型名与"无法关闭"字样;`enable_thinking` 任意非 `None` + `openai` profile 抛 `ValueError` 且消息含 `register_provider` 与 `extra_body` 两个指路词;`register_capability` 不修改 `DEFAULT_CAPABILITIES`。
|
||||||
|
|
||||||
|
**测试证据**:先写五条判定的参数化测试 → 失败(函数不存在)→ 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_providers.py -v` → PASS。
|
||||||
|
|
||||||
|
### T6 — transport 接入与请求期兜底
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/transports/openai_compat.py`;改测 `tests/unit/test_openai_compat.py`
|
||||||
|
|
||||||
|
**行为**:`OpenAICompatTransport.__init__` 增加 `capabilities: Mapping[str, ThinkingCapability] | None = None`,与既有 `registry` 参数同形并存于 `self._capabilities`。`_build_payload` 的两个 `if` 分支(`:293-296`)收敛为两行——先 `capability = get_capability(source.model, table=self._capabilities)`,再 `payload.update(resolve_thinking(profile, capability, source.enable_thinking, model=source.model))`;其后 `payload.update(source.extra_body)` 与 `payload.update(overlay)` 两行**顺序不变**(顺序即优先级,issue #4 决策 A)。`complete()` 中把构造 payload 的 `ValueError` 翻译为 `RequestRejectedError`(四分类之一,不重试不换源)。
|
||||||
|
|
||||||
|
**绝不在 `_build_payload` 内抛裸 `ValueError` 让它冒泡**:该处位于 RetryMW 内侧,裸异常不属错误四分类、`TelemetryMW` 也不捕,会导致一行遥测都没有就逃出 `chat()`。
|
||||||
|
|
||||||
|
**验收**:`enable_thinking=True` + minimax 源 → 请求体含 `reasoning_effort: "medium"`;`False` → `"none"`;`None` → 请求体无 `reasoning_effort` 键;`extra_body={"reasoning_effort":"high"}` 时实发 `high`(覆盖 profile);`enable_thinking=False` + M2.7 源经 transport 调用 → `RequestRejectedError` 而非裸 `ValueError`。
|
||||||
|
|
||||||
|
**测试证据**:扩 `tests/unit/test_openai_compat.py:418-431` 的三态参数化,加 minimax 用例 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_openai_compat.py -v` → PASS。
|
||||||
|
|
||||||
|
### T7 — 装配守卫、参数贯通与缓存指纹
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `src/polygateway/client.py`;改测 `tests/unit/test_cache.py`(指纹相关)、新增装配守卫测试至 `tests/unit/test_config.py`
|
||||||
|
|
||||||
|
**行为**(三件事,同一文件):
|
||||||
|
|
||||||
|
其一,`from_settings` 与 `from_env` 各增加 `capabilities` 参数并透传给 `OpenAICompatTransport`;`from_settings` 在已解析 `profiles` 之后(`client.py:248`)加装配守卫:对 `zip(sources, profiles, strict=True)` 的每一对,先 `get_capability(src.model, table=capabilities)` 取能力,再调用一次 `resolve_thinking(prof, cap, src.enable_thinking, model=src.model)` 并丢弃返回值——只为让配置错误在装配期即抛 `ValueError`。守卫与 transport 内的判定共用同一函数,不复制逻辑——这与 `get_provider` 在 `client.py:248` 与 `openai_compat.py:313` 双点调用的既有形态一致。
|
||||||
|
|
||||||
|
其二,`build_model_fingerprint`(`client.py:63-80`)把 `enable_thinking` 纳入摘要。实现必须保持既有不变量——**全源不配 `enable_thinking` 时指纹字面量与改动前逐字相同**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fingerprint_mark(s: SourceConfig) -> str:
|
||||||
|
parts: list[Any] = [s.model, dict(s.extra_body)]
|
||||||
|
if s.enable_thinking is not None: # 仅在表态时追加,保证存量指纹字面量不变
|
||||||
|
parts.append(s.enable_thinking)
|
||||||
|
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
筛选条件由 `if s.extra_body` 扩为 `if s.extra_body or s.enable_thinking is not None`。
|
||||||
|
|
||||||
|
其三,为守卫补测:`enable_thinking=False` + `provider=minimax` + `model=MiniMax-M2.7` 的 `GatewaySettings` 经 `from_settings` → `ValueError`;`provider=openai` + 任意非 `None` 的 `enable_thinking` → `ValueError`。
|
||||||
|
|
||||||
|
**验收**:装配期报错两例;改 `enable_thinking` → 指纹变化;只配 `extra_body`、不配 `enable_thinking` 的源 → 指纹与改动前逐字相同(用硬编码的历史字面量断言,防回归)。
|
||||||
|
|
||||||
|
**测试证据**:先写三条断言 → 失败 → 实现 → 通过。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/unit/test_cache.py tests/unit/test_config.py -v` → PASS;随后 `conda run -n PolyGateway make ci` → 全绿。
|
||||||
|
|
||||||
|
### T8 — 真实 API e2e 矩阵
|
||||||
|
|
||||||
|
- [ ] **文件**:新建 `tests/e2e/test_thinking_live.py`
|
||||||
|
|
||||||
|
**行为**:沿用既有 e2e 约定(`tests/e2e/test_smoke_gateway.py:19-26`)——`dotenv_values(".env")` 读凭据、`skipif(not _HAS_SOURCE, ...)`、结构化报告写入 `tests/outputs/e2e/`。**不新造开关机制**:另加项目既有的 `slow` 标记,靠 `pyproject.toml` 的 `addopts = "-m 'not slow'"` 把本组挡在 `make ci` 之外(137 次真实调用、约 7 分钟,且判据是统计性的,网络抖动会造成假红——执行期实测撞到过一次 `network_error` 耗尽源)。合并前用 `pytest -m slow tests/e2e/test_thinking_live.py` 显式真跑。
|
||||||
|
|
||||||
|
**源映射**:L1–L5、L8 的 MiniMax 行用现有的 `LLM__MINIMAX__1__*`(`MODEL=MiniMax-M3`);M2.7 / M2.5 行经 `dataclasses.replace(source, model=...)` 派生,不新增 `.env` 键。**L6 / L7 目前无对应源**——`.env` 里只有 MINIMAX 与 MONKEY 两类;需新增 `{SCOPE}__QWEN__1__*` 与 `{SCOPE}__DEEPSEEK__1__*`(同一中转 `BASE_URL` 与密钥,仅 `MODEL` 不同)。未配置时按既有 `skipif` 约定跳过,并在报告中记为「未覆盖」,**不得静默计入通过**。
|
||||||
|
|
||||||
|
覆盖矩阵(轮数经环境变量可调,默认值如下):
|
||||||
|
|
||||||
|
| # | 场景 | 源 | 轮数 | 判据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| L1 | `enable_thinking=False` | MiniMax-M3 | 10 | **每轮** `completion_tokens < 30`(主判据)且 `reasoning_tokens in (None, 0)`(辅判据,与下游口径一致) |
|
||||||
|
| L2 | `enable_thinking=True` | MiniMax-M3 | 10 | 多数轮 `completion_tokens > 100`;请求体实发 `reasoning_effort=medium` |
|
||||||
|
| L3 | `enable_thinking=None` | MiniMax-M3 | 10 | 请求体无 `reasoning_effort` 键 |
|
||||||
|
| L4 | `extra_body` 覆盖 profile | MiniMax-M3 | 5 | 实发 `high` |
|
||||||
|
| L5 | L1 / L2 的**流式**重跑 | MiniMax-M3 | 各 10 | 同 L1 / L2(`stream=True` 是库的默认主路径) |
|
||||||
|
| L6 | `enable_thinking=False` | qwen | 10 | 每轮 `completion_tokens < 30` |
|
||||||
|
| L7 | `enable_thinking=False` | deepseek | 10 | 每轮 `completion_tokens < 30` |
|
||||||
|
| L8 | 能力表漂移哨兵 | 全部登记模型 | 各 5 | 实测行为与 `can_disable` 声明一致 |
|
||||||
|
| L9 | M2.7 + `enable_thinking=False` → 装配期报错 | — | — | 纯本地,无需真实调用 |
|
||||||
|
|
||||||
|
**三条必须遵守的测试纪律**:
|
||||||
|
|
||||||
|
其一,**判别量只能是 `reasoning_tokens`**。(执行时按 e2e 实测修正:本条初稿写的是「主判据用 `completion_tokens`」,被数据推翻——两档的输出长度分布**重叠**,关闭档实测最高 46、开启档最低 13,按长度阈值判两个方向都会误判。)`completion_tokens` 仅作 `reasoning_tokens` 被中转吃掉时的退路(findings §4c、§2.5)。
|
||||||
|
|
||||||
|
其二,**关闭方向要求每轮满足,开启方向只要求多数轮满足**。中转吃掉 ctd 时开启方向可能偶尔观测不到,关闭方向不受影响。
|
||||||
|
|
||||||
|
其四,**必须有不依赖输出侧噪声的锚点**:L2b 比较两档的 `prompt_tokens`(相对比较,无魔数),L3b 用非法值反证 `none` 是被识别而非被静默丢弃——后者正是 issue #5 的原始故障形态,不排除它,关闭方向的证据就只到「未回归」,够不到「已生效」。
|
||||||
|
|
||||||
|
其三,**源不可用必须跳过并在报告中显式记为「未覆盖」**,不得静默计入通过(实测中 kimi 渠道 429 后被中转下线并返回 404)。报告要能一眼看出哪些矩阵行没跑到。
|
||||||
|
|
||||||
|
**报告内容**(`tests/outputs/e2e/test_thinking_live_<ts>.md`):逐轮记录实际注入的 thinking 片段、`prompt_tokens` / `completion_tokens` / `reasoning_tokens`、单轮判定结果;逐行记录矩阵编号、通过或跳过及其原因;文末给出总调用次数与时间戳。原始数字必须落盘——结论可以复核,才算证据。
|
||||||
|
|
||||||
|
**验收**:矩阵九行全部有结论(通过 / 明确跳过),报告落盘 `tests/outputs/e2e/`。
|
||||||
|
|
||||||
|
**验证**:`conda run -n PolyGateway pytest tests/e2e/test_thinking_live.py -v -s` → PASS,人工核对报告。
|
||||||
|
|
||||||
|
### T9 — 文档同步与收尾
|
||||||
|
|
||||||
|
- [ ] **文件**:改 `CHANGELOG.md`、`research-wiki/schemas/llm-calls.md`
|
||||||
|
|
||||||
|
**行为**:CHANGELOG 必须醒目标注这是**行为变更而非纯修复**——MiniMax 源的 `ENABLE_THINKING` 从「无效」变为「生效」,且配了该项的 scope 会有一次性缓存冷启动。同时写明 `reasoning_tokens` 的语义:`None` = 本次调用未上报,下游判据须为 `in (None, 0)`,写 `== 0` 永远不成立。`schemas/llm-calls.md` 的字段表由 21 改 22,新增行说明该列。
|
||||||
|
|
||||||
|
Gitea Wiki(独立仓库)**本任务内必须同步**:按 `docs-convention.md` §2「新公共 API / 新能力」一行,需改 `参考-公共API`(`LLMResponse` 新字段)与相关指南页;该表把同步绑定在**变更**上而非发版上,不可推迟。`Home.md` 的版本号与安装命令等发版项不在本计划范围。
|
||||||
|
|
||||||
|
**验收**:CHANGELOG 含行为变更与冷启动两处提示;schema 文档字段数与 `_COLUMNS` 长度一致。
|
||||||
|
|
||||||
|
**验证**:人工核对;`conda run -n PolyGateway make ci` → 全绿。
|
||||||
|
|
||||||
|
## 完成后的独立验证
|
||||||
|
|
||||||
|
按 `verification-before-completion` 的强制档,本计划跨多文件,合并前须派**全新上下文**的 verifier subagent 逐条核对设计 §11 的九条验收标准与本计划各任务的测试证据,不得自审代替。
|
||||||
|
|
||||||
|
### T10 — 同步结论给 dissect
|
||||||
|
|
||||||
|
- [ ] **动作**:在本分支合并时,向 dissect 提一条 issue 或在其 `ROADMAP` 风险表中记录下述结论,并确认对方已读。
|
||||||
|
|
||||||
|
**验收**:dissect 侧存在可追溯的记录(issue 编号或文档行号),不以口头告知为准。
|
||||||
|
|
||||||
|
## 需要同步给下游的结论
|
||||||
|
|
||||||
|
`MiniMax-M2.7` / `M2.5` 的推理**关不掉**是模型固有属性,任何库层改动都无法改变。dissect 的 Phase-0 若要做「开思考 vs 关思考」对照,只能在 M3 上做,或把因子改为「高档 vs 低档」。此结论须在本分支合并时同步给 dissect。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:response-observability-fields
|
||||||
|
title: 响应可观测字段扩展实现计划
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 响应可观测字段扩展实现计划
|
||||||
|
|
||||||
|
全文见 `2026-07-31-response-observability-fields.md`。实现 [[response-observability-fields]] 设计(A2/B1/C1/D1)。
|
||||||
|
|
||||||
|
## 任务序列
|
||||||
|
|
||||||
|
| 任务 | 内容 | 提交 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | `types.py` 两个类型各 +2 字段;`cache_hit` docstring 消歧 | 独立 |
|
||||||
|
| T2 | `openai_compat.py` 防御解析 + SSE sink 采集 `model` + 两处构造填值 | 独立 |
|
||||||
|
| T3 | `retry.py` 搬运;`cache.py` 零改动但用测试固化 B1 回放语义 | 独立 |
|
||||||
|
| T4 | `pricing.py` 可选缓存单价档 + 夹取防负 | 独立 |
|
||||||
|
| T5+T6 | 端口 18→20、两后端 DDL 加列与幂等补列、emitter 搬运、契约测试 | **必须合一次提交** |
|
||||||
|
| T7 | ARCHITECTURE §7.8 / CHANGELOG / `.env.example:56` / 6 处「18 字段」措辞 / 版本 1.1.0 / Gitea Wiki 站 | 独立 |
|
||||||
|
|
||||||
|
## 独立审查抓出的四个坑(已折回计划)
|
||||||
|
|
||||||
|
1. **DDL 新列必须放在 `created_at` 之后**(表末尾)。旧表走 `ALTER ADD COLUMN` 只能追加到末尾,若新建库把新列插在 `created_at` 前,两条路径列序分叉 —— 而 `test_schema_has_frozen_columns_in_order` 按 `ordinal_position` 逐位断言,且该 PG 表与真实批跑共享、严禁 DROP,分叉后无合规修法。
|
||||||
|
2. **SQLite 补列块首行必须守卫 `if self._conn is None: return`**。否则初始化失败时补列块抛 `AttributeError`/`NameError`(不被 `sqlite3.Error` 捕获)逃出 `__init__`,打破「初始化失败静默降级」契约。
|
||||||
|
3. **T5 与 T6 不得分开提交**。中间状态下 emitter 只传 18 键,后端抛 `KeyError` 被吞成 warning,该 commit 全量遥测静默丢失。
|
||||||
|
4. **天然拦截点是四处而非三处**:两个 `_record_minimal` + 两个 `_EXPECTED_COLUMNS`;`test_ports.py:96` 的全签名 fake **不会**红(Protocol 的 isinstance 不校验签名),不能当作覆盖保证。
|
||||||
|
|
||||||
|
相关: [[response-observability-fields]]、[[est-tokens-decoupling]]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:sampling-params-plan
|
||||||
|
title: "采样参数透传实现计划(issue #4)"
|
||||||
|
date: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# 采样参数透传实现计划(issue #4)
|
||||||
|
|
||||||
|
正文: `2026-07-31-sampling-params.md`。实现 `design:sampling-params`。
|
||||||
|
|
||||||
|
- **任务数**: 11 个,每个一次提交、独立可验证。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中「issue 未提但必须处理」的部分(地基不变式、遥测三入口、两后端落列、决策 G 剥离),不可跳过。
|
||||||
|
- **关键接口已在计划 §1 定死**: `validate_request_overlay()` / `merge_sampling()` / `canonical_sampling_json()` 三个纯函数落 `types.py`(最内层),`ChatRequest.sampling`、`SourceConfig.extra_body` 两个新字段,`build_cache_key()` 与 `chat()` 的新签名。
|
||||||
|
- **审查暴露的执行陷阱(已写进计划)**: ① 两个 `_record_minimal()` 的硬编码 20 键 fields dict 必须同步,否则 `row = tuple(fields[col] for col in _COLUMNS)`(在 try 之外)抛裸 `KeyError` 让两侧落库测试全红;② `SourceConfig` 加 mapping 字段后不再 hashable、`asdict`/`deepcopy` 失效——已核实库内无调用点会踩,作为已知后果显式接受并加锁定测试;③ 各文件需新增的 import 逐一列出(`types.py` 无 `from __future__ import annotations`,注解在类体求值);④ `validate_request_overlay` 的校验顺序必须先查 str 键再试序列化,否则非 str 键会被误报成"值不可序列化"。
|
||||||
|
- **测试证据门**: 每个任务合并前须出示先失败后通过的证据。Task 5 单列一条地基不变式回归——决策 C/D 都建立在「`sampling` 跨层恒定」之上,而这条目前只靠 `dataclasses.replace` 的约定,无机械执法;该测试须在故意破坏 `structured.py` 时验证过确实变红。
|
||||||
|
- **共享后端纪律**: Task 7、Task 11 涉及 PG `polygateway` 库,严禁与其他会话并跑(含 git 钩子触发的测试)。
|
||||||
|
- **审查留痕**: Codex CLI 不可用(vendor 二进制缺失),派全新上下文 subagent 只读审查。报 5 项必修(两处测试文件路径不存在、`_record_minimal` 漏项、Gitea Wiki 同步漏整块、`SourceConfig` 可哈希性后果未声明),逐条核实后全部采纳;5 条建议(import 清单、校验顺序、Task 5 落点表述、`make ci` 勿嵌套 `conda run`、`test_ports.py` 的 `_DummyRecorder` 同步)亦已收进。
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Query Pack
|
# Query Pack
|
||||||
|
|
||||||
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
|
> 自动生成,请勿手动编辑。
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
type: schema
|
type: schema
|
||||||
node_id: schema:llm-calls
|
node_id: schema:llm-calls
|
||||||
title: "表结构: llm_calls(遥测 18 字段)"
|
title: "表结构: llm_calls(遥测 22 字段)"
|
||||||
date: 2026-07-20
|
date: 2026-07-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# 表结构: llm_calls(遥测 18 字段)
|
# 表结构: llm_calls(遥测 22 字段)
|
||||||
|
|
||||||
|
|
||||||
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
|
||||||
@@ -24,6 +24,10 @@ date: 2026-07-20
|
|||||||
| error | TEXT | 异常信息;取消记 "cancelled" |
|
| error | TEXT | 异常信息;取消记 "cancelled" |
|
||||||
| cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
|
| cost | REAL | M2 起 pricing 换算;`usage_source='unavailable'` 的真实调用行为 NULL(缓存命中行例外,仍为 0.0) |
|
||||||
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
|
| created_at | TEXT NOT NULL DEFAULT (datetime('now')) | 落库时刻 |
|
||||||
|
| cached_prompt_tokens | INTEGER | 供应商 prompt cache 命中的输入 token(2026-07-31,issue #3);NULL = 该源未上报,`0` = 上报了真实零命中,两者不可混同 |
|
||||||
|
| model_reported | TEXT | API 响应体实际返回的 model;NULL = 未上报。与 `model`(配置别名)可能分叉 |
|
||||||
|
| sampling | TEXT | 本次调用的采样参数 canonical JSON(2026-07-31,issue #4);NULL = 未传。见下方口径 |
|
||||||
|
| reasoning_tokens | INTEGER | 推理消耗的输出 token(2026-08-02,issue #6);**含在 completion_tokens 内**,不影响成本总额,只补归因。NULL = **本次调用**未上报 |
|
||||||
|
|
||||||
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
## usage/成本口径(2026-07-30,est_tokens 解耦)
|
||||||
|
|
||||||
@@ -35,6 +39,44 @@ date: 2026-07-20
|
|||||||
|
|
||||||
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
`SUM(cost)` 天然跳过 NULL,故账单汇总不再被虚构的估值污染;账目缺口的度量口径固定为 `WHERE usage_source = 'unavailable' AND cache_hit = false`。**`cache_hit` 限定不可省**:缓存命中行未产生新调用,cost 是事实上的 `0.0` 而非未知,本无账目缺口,漏掉该条件会让缺口度量偏高。
|
||||||
|
|
||||||
|
## 供应商 prompt cache 口径(2026-07-31,issue #3)
|
||||||
|
|
||||||
|
新增两列排在 `created_at` **之后**——旧表只能经 `ALTER TABLE ADD COLUMN` 追加到末尾,DDL 里若插在前面,新建库与升级库的物理列序会分叉(列序断言无合规修法)。两个后端在初始化期幂等补列:`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入被逐行 warning 丢弃、遥测静默全失。两侧都**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,遥测是内联 await,锁共享审计表会拖垮业务调用),且**补列失败只降级为逐行丢弃,绝不让 recorder 整体失能**——两侧纪律必须对称。
|
||||||
|
|
||||||
|
`cache_hit` 指 **PolyGateway 自身响应缓存**,与供应商 prompt cache 是两回事。缓存命中行的这两列是**原样回放**的历史值(与 `model`/`prompt_tokens` 同一口径),故命中率度量口径固定为:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0)
|
||||||
|
FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
`WHERE cache_hit = false` 不可省,理由与上面 cost 缺口口径同源:回放行计入即重复计数。
|
||||||
|
|
||||||
|
## 采样参数口径(2026-07-31,issue #4)
|
||||||
|
|
||||||
|
`reasoning_tokens` 的 NULL 语义与 `cached_prompt_tokens` **不同**: 后者的 NULL 是"该源不报这个数",前者只能读作"**本次调用**未上报"——中转在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(实测同一请求 10 轮呈 6:4 双峰)。故统计口径须为 `IS NULL OR = 0` 才算"未推理",写 `= 0` 的条件永远不成立——实测三家供应商在未推理时都是整个 details 缺失,无人上报字面 `0`。**不可用 `completion_tokens` 反推是否推理**: 两档的输出长度分布重叠(关闭档实测最高 46,开启档最低 13)。
|
||||||
|
|
||||||
|
`sampling` 列 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化输出注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。补列纪律与 issue #3 两列逐字相同(排在末尾、先探测再 ALTER、失败只逐行降级)。
|
||||||
|
|
||||||
|
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
|
||||||
|
|
||||||
|
| 入口 | 调用者 | 有生效源? | 记什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
|
||||||
|
| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` |
|
||||||
|
| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` |
|
||||||
|
|
||||||
|
后两行缺 `extra_body` 是客观事实而非口径瑕疵——它们没有"生效源"可言,与 `model`/`source_name` 在终态行置空是同一先例;缓存命中行亦无损:`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。
|
||||||
|
|
||||||
|
OCR / embedding 路径的该列**恒为 NULL**:两条路径的 transport 不发 `extra_body`(embed payload 硬编码 `{model, input}`、MonkeyOCR 只发 multipart),故其源在构造期就被剥离——不剥离则该列会记录一个从未发出的参数,那是数据造假而非参数失效。
|
||||||
|
|
||||||
|
复现某批实验的解码条件:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT sampling FROM llm_calls
|
||||||
|
WHERE session_id = $1 AND cache_hit = false AND error IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
## 埋点位置(单一 helper 铁律)
|
## 埋点位置(单一 helper 铁律)
|
||||||
|
|
||||||
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
- `middleware/telemetry.py::TelemetryEmitter` 是全库**唯一** `record_llm_call` 调用点;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.0.3"
|
__version__ = "1.0.6"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||||
@@ -23,7 +25,7 @@ from polygateway.middleware.retry import RetryMW
|
|||||||
from polygateway.middleware.structured import StructuredMW
|
from polygateway.middleware.structured import StructuredMW
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW
|
||||||
from polygateway.pricing import PricingTable
|
from polygateway.pricing import PricingTable
|
||||||
from polygateway.providers import get_provider
|
from polygateway.providers import get_capability, get_provider, resolve_thinking
|
||||||
from polygateway.sources import (
|
from polygateway.sources import (
|
||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
HealthAwareSelector,
|
HealthAwareSelector,
|
||||||
@@ -32,7 +34,7 @@ from polygateway.sources import (
|
|||||||
SourceCooldownMemo,
|
SourceCooldownMemo,
|
||||||
)
|
)
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import ChatRequest, LLMResponse
|
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Iterable, Mapping
|
from collections.abc import Awaitable, Iterable, Mapping
|
||||||
@@ -49,7 +51,7 @@ if TYPE_CHECKING:
|
|||||||
TelemetryRecorder,
|
TelemetryRecorder,
|
||||||
Transport,
|
Transport,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile
|
from polygateway.providers import ProviderProfile, ThinkingCapability
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
@@ -59,6 +61,59 @@ if TYPE_CHECKING:
|
|||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_thinking(
|
||||||
|
sources: list[SourceConfig],
|
||||||
|
profiles: list[ProviderProfile],
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None,
|
||||||
|
) -> None:
|
||||||
|
"""装配期把不可满足的推理开关炸掉,而不是留到运行时(issue #5)。
|
||||||
|
|
||||||
|
与 transport 内的同一次判定不是重复: 那里兜的是"构造函数全量注入"这条路
|
||||||
|
(CLAUDE.md §4.5 的第二条装配路),而工厂路占 90% 场景,配置错误应当在装配期
|
||||||
|
就带着指路信息炸掉。`get_provider` 现在就是同一形态的双点调用。
|
||||||
|
"""
|
||||||
|
for source, profile in zip(sources, profiles, strict=True):
|
||||||
|
resolve_thinking(
|
||||||
|
profile,
|
||||||
|
get_capability(source.model, table=capabilities),
|
||||||
|
source.enable_thinking,
|
||||||
|
model=source.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint_mark(source: SourceConfig) -> str:
|
||||||
|
"""单源的指纹标记;`enable_thinking` 仅在**表态时**追加。
|
||||||
|
|
||||||
|
只在表态时追加不是省事: 这样只配了 `extra_body` 的存量源字面量与 issue #4
|
||||||
|
时期逐字相同,升级本版本不会给它们平白来一次全量缓存冷启动。
|
||||||
|
"""
|
||||||
|
parts: list[Any] = [source.model, dict(source.extra_body)]
|
||||||
|
if source.enable_thinking is not None:
|
||||||
|
parts.append(source.enable_thinking)
|
||||||
|
return json.dumps(parts, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str:
|
||||||
|
"""缓存 key 的模型身份: 多源 scope = 排序去重的 model 合集。
|
||||||
|
|
||||||
|
配置级采样参数(`extra_body`)必须参与,否则把 temperature 从 0 改成 1
|
||||||
|
后重启仍会读到旧缓存(issue #4 设计决策 C)。`enable_thinking` 同理
|
||||||
|
(issue #5): 它一旦真正改变请求体,"关掉推理后重启"就会读到开着推理时
|
||||||
|
缓存的旧响应。全源两者皆未表态时字面量与历史实现逐字相同,不触发存量
|
||||||
|
缓存冷启动。
|
||||||
|
"""
|
||||||
|
fingerprint = ",".join(sorted({s.model for s in sources}))
|
||||||
|
# 按 (model, extra_body[, enable_thinking]) 而非源名摘要: 语义是"本 scope
|
||||||
|
# 会用哪些(模型, 请求形态)组合",改源名不该误触全量冷启动
|
||||||
|
marks = sorted(
|
||||||
|
{_fingerprint_mark(s) for s in sources if s.extra_body or s.enable_thinking is not None}
|
||||||
|
)
|
||||||
|
if marks:
|
||||||
|
digest = hashlib.sha256("".join(marks).encode("utf-8")).hexdigest()
|
||||||
|
fingerprint = f"{fingerprint}|{digest}"
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
|
||||||
class GatewayClient:
|
class GatewayClient:
|
||||||
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
|
"""统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。"""
|
||||||
|
|
||||||
@@ -113,12 +168,11 @@ class GatewayClient:
|
|||||||
if cache is not None:
|
if cache is not None:
|
||||||
if cache_namespace is None or cache_ttl_s is None:
|
if cache_namespace is None or cache_ttl_s is None:
|
||||||
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
|
raise ValueError("启用缓存必须提供 cache_namespace 与 cache_ttl_s")
|
||||||
# 多源 scope 的 key 身份 = 排序去重的 model 合集;源集合变化 → 一次性冷启动
|
# 多源 scope 的 key 身份;源集合或其 extra_body 变化 → 一次性冷启动
|
||||||
fingerprint = ",".join(sorted({s.model for s in sources}))
|
|
||||||
middlewares.append(
|
middlewares.append(
|
||||||
CacheMW(
|
CacheMW(
|
||||||
backend=cache,
|
backend=cache,
|
||||||
model_fingerprint=fingerprint,
|
model_fingerprint=build_model_fingerprint(sources),
|
||||||
default_namespace=cache_namespace,
|
default_namespace=cache_namespace,
|
||||||
ttl_s=cache_ttl_s,
|
ttl_s=cache_ttl_s,
|
||||||
strategy=structured_strategy,
|
strategy=structured_strategy,
|
||||||
@@ -150,12 +204,23 @@ class GatewayClient:
|
|||||||
cache_namespace: str | None = None,
|
cache_namespace: str | None = None,
|
||||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||||
stream: bool = True,
|
stream: bool = True,
|
||||||
|
overlay: Mapping[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。"""
|
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||||
|
|
||||||
|
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||||
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
|
参数不影响既有调用点(issue #4)。
|
||||||
|
"""
|
||||||
if structured is not None and not self._structured_available:
|
if structured is not None and not self._structured_available:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||||
)
|
)
|
||||||
|
# 进洋葱之前校验并拷贝: 保护键/不可序列化值在此收口(否则会在 CacheMW
|
||||||
|
# 的降级 try 之外抛裸 TypeError);拷贝防调用方复用同一 dict 逐次改 seed
|
||||||
|
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
||||||
|
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
||||||
|
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||||
request = ChatRequest(
|
request = ChatRequest(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -164,6 +229,8 @@ class GatewayClient:
|
|||||||
cache_namespace=cache_namespace,
|
cache_namespace=cache_namespace,
|
||||||
structured=structured,
|
structured=structured,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
|
overlay=sampling,
|
||||||
|
sampling=sampling,
|
||||||
)
|
)
|
||||||
return await self._handler(request)
|
return await self._handler(request)
|
||||||
|
|
||||||
@@ -204,11 +271,13 @@ class GatewayClient:
|
|||||||
cache: CacheBackend | None = None,
|
cache: CacheBackend | None = None,
|
||||||
telemetry: TelemetryRecorder | None = None,
|
telemetry: TelemetryRecorder | None = None,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
rng: Any = random.random,
|
rng: Any = random.random,
|
||||||
) -> GatewayClient:
|
) -> GatewayClient:
|
||||||
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
"""按配置装配;显式传入的后端实例即共享(None 项按配置自建私有实例)。"""
|
||||||
sources = list(settings.sources)
|
sources = list(settings.sources)
|
||||||
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
profiles = [get_provider(s.provider, registry=registry) for s in sources]
|
||||||
|
_guard_thinking(sources, profiles, capabilities)
|
||||||
strategy, escalation = _build_structured(profiles)
|
strategy, escalation = _build_structured(profiles)
|
||||||
return cls(
|
return cls(
|
||||||
scope=settings.scope,
|
scope=settings.scope,
|
||||||
@@ -216,7 +285,7 @@ class GatewayClient:
|
|||||||
selector=_build_selector(settings.selector, rng=rng),
|
selector=_build_selector(settings.selector, rng=rng),
|
||||||
limiter=limiter or _build_limiter(settings, sources),
|
limiter=limiter or _build_limiter(settings, sources),
|
||||||
breaker=breaker or _build_breaker(settings),
|
breaker=breaker or _build_breaker(settings),
|
||||||
transport=OpenAICompatTransport(registry=registry),
|
transport=OpenAICompatTransport(registry=registry, capabilities=capabilities),
|
||||||
retry=settings.retry,
|
retry=settings.retry,
|
||||||
backpressure=settings.backpressure,
|
backpressure=settings.backpressure,
|
||||||
quota_full=settings.quota_full,
|
quota_full=settings.quota_full,
|
||||||
@@ -242,6 +311,7 @@ class GatewayClient:
|
|||||||
cache: CacheBackend | None = None,
|
cache: CacheBackend | None = None,
|
||||||
telemetry: TelemetryRecorder | None = None,
|
telemetry: TelemetryRecorder | None = None,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
) -> GatewayClient:
|
) -> GatewayClient:
|
||||||
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
"""从 .env/环境变量装配一个 scope 的 client(键名清单见 .env.example)。"""
|
||||||
@@ -252,6 +322,7 @@ class GatewayClient:
|
|||||||
cache=cache,
|
cache=cache,
|
||||||
telemetry=telemetry,
|
telemetry=telemetry,
|
||||||
registry=registry,
|
registry=registry,
|
||||||
|
capabilities=capabilities,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fail-loud 校验语义与 pydantic-settings 一致。
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -44,6 +45,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
"ENABLE_THINKING": ("enable_thinking", "bool"),
|
||||||
"MISSING_DONE": ("missing_done", "str"),
|
"MISSING_DONE": ("missing_done", "str"),
|
||||||
"TRUST_ENV": ("trust_env", "bool"),
|
"TRUST_ENV": ("trust_env", "bool"),
|
||||||
|
"EXTRA_BODY": ("extra_body", "json"),
|
||||||
}
|
}
|
||||||
_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"})
|
||||||
@@ -74,6 +76,12 @@ def _cast(raw: str, kind: str, key: str) -> object:
|
|||||||
if lowered in ("0", "false", "no", "off"):
|
if lowered in ("0", "false", "no", "off"):
|
||||||
return False
|
return False
|
||||||
raise ValueError(f"非法布尔值: {raw!r}")
|
raise ValueError(f"非法布尔值: {raw!r}")
|
||||||
|
if kind == "json":
|
||||||
|
# JSONDecodeError 是 ValueError 子类,复用下方的统一包装
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError(f"必须是 JSON 对象(而非数组/标量): {raw!r}")
|
||||||
|
return parsed
|
||||||
return raw
|
return raw
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
|
raise ValueError(f"配置 {key} 解析失败: {exc}") from exc
|
||||||
@@ -141,9 +149,11 @@ class GatewaySettings:
|
|||||||
def _normalize(self) -> None:
|
def _normalize(self) -> None:
|
||||||
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||||
|
|
||||||
`scope` 最要紧: 它直接进 Redis key(`pgw:limit:{scope}:…`/`pgw:gate:{scope}:…`)。
|
`scope` 的 strip 才是要紧的那一半: 它进 Redis key(`pgw:limit:{scope}:…`
|
||||||
一个进程走 `from_env("LLM")` 拿到 "llm"、另一个直接构造传 "LLM",同一逻辑
|
/`pgw:gate:{scope}:…`),而两个 Redis 后端在构造函数里只 `.lower()` **不 strip**
|
||||||
scope 的限流与熔断状态会分裂到两套命名空间,各记各的,治理静默失效且不报错。
|
——`"llm "` 会产出 `pgw:limit:llm :…`,与 `from_env` 路的进程分裂成两套命名空间。
|
||||||
|
大小写则不会: 后端自 v1.0.0 起各自 lower,`from_settings` 传 "LLM" 也落在同一
|
||||||
|
套 key 上(此处 lower 只为让 `GatewaySettings.scope` 属性两路取值一致)。
|
||||||
|
|
||||||
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
空串归 None 同理: 留着空串会骗过 `is None` 判断,把错误推迟到 redis 客户端
|
||||||
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
抛连接串解析异常。`telemetry_pg_dsn` 的驱动后缀因为要看 backend 且需告警,
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ from polygateway.middleware.ratelimit import QuotaGate
|
|||||||
from polygateway.middleware.retry import _failure_reason, backoff_delay
|
from polygateway.middleware.retry import _failure_reason, backoff_delay
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
from polygateway.sources import SourceCooldownMemo
|
from polygateway.sources import SourceCooldownMemo
|
||||||
from polygateway.types import ChatRequest, EmbeddingResponse, LLMResponse
|
from polygateway.types import (
|
||||||
|
ChatRequest,
|
||||||
|
EmbeddingResponse,
|
||||||
|
LLMResponse,
|
||||||
|
strip_unsupported_extra_body,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
@@ -111,7 +116,9 @@ class EmbeddingClient:
|
|||||||
if expected_dim is not None and expected_dim < 1:
|
if expected_dim is not None and expected_dim < 1:
|
||||||
raise ValueError("expected_dim 必须 ≥ 1")
|
raise ValueError("expected_dim 必须 ≥ 1")
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
self._sources = list(sources)
|
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
||||||
|
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||||
|
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
||||||
self._selector = selector
|
self._selector = selector
|
||||||
self._quota = QuotaGate(limiter)
|
self._quota = QuotaGate(limiter)
|
||||||
self._breaker = BreakerGate(breaker)
|
self._breaker = BreakerGate(breaker)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from loguru import logger
|
|||||||
from polygateway.types import ChatRequest, LLMResponse
|
from polygateway.types import ChatRequest, LLMResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy
|
from polygateway.ports import CacheBackend, CallNext, StructuredOutputStrategy
|
||||||
|
|
||||||
_KEY_PREFIX = "pgw:cache:"
|
_KEY_PREFIX = "pgw:cache:"
|
||||||
@@ -50,9 +52,19 @@ def _digest_part(part: Any) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def build_cache_key(
|
def build_cache_key(
|
||||||
model_fingerprint: str, messages: list[dict[str, Any]], namespace: str, salt: str | None
|
model_fingerprint: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
namespace: str,
|
||||||
|
salt: str | None,
|
||||||
|
*,
|
||||||
|
sampling: Mapping[str, Any] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。"""
|
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||||
|
|
||||||
|
`sampling` 仅**非空**时参与(与 salt 的"仅非 None"不同——空串是有意义的
|
||||||
|
salt,而空采样参数与不传无语义差别)。它必须进 key: 否则同 messages 跑 5 个
|
||||||
|
seed 会全部命中第一次的响应,标准差恒为 0 且不报错(issue #4 决策 C)。
|
||||||
|
"""
|
||||||
key_obj: dict[str, Any] = {
|
key_obj: dict[str, Any] = {
|
||||||
"model": model_fingerprint,
|
"model": model_fingerprint,
|
||||||
"messages": digest_messages(messages),
|
"messages": digest_messages(messages),
|
||||||
@@ -60,6 +72,8 @@ def build_cache_key(
|
|||||||
}
|
}
|
||||||
if salt is not None:
|
if salt is not None:
|
||||||
key_obj["salt"] = salt
|
key_obj["salt"] = salt
|
||||||
|
if sampling:
|
||||||
|
key_obj["sampling"] = dict(sampling)
|
||||||
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
||||||
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
@@ -92,7 +106,15 @@ class CacheMW:
|
|||||||
|
|
||||||
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
|
||||||
namespace = request.cache_namespace or self._namespace
|
namespace = request.cache_namespace or self._namespace
|
||||||
key = build_cache_key(self._fingerprint, request.messages, namespace, request.cache_salt)
|
# 读 sampling 而非 overlay: 语义明确,且不依赖"CacheMW 恰在 StructuredMW
|
||||||
|
# 外侧"这一层序巧合——结构化注入不该改变缓存身份(设计决策 C)
|
||||||
|
key = build_cache_key(
|
||||||
|
self._fingerprint,
|
||||||
|
request.messages,
|
||||||
|
namespace,
|
||||||
|
request.cache_salt,
|
||||||
|
sampling=request.sampling,
|
||||||
|
)
|
||||||
cached = await self._safe_get(key)
|
cached = await self._safe_get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
hit = self._rehydrate(cached, request)
|
hit = self._rehydrate(cached, request)
|
||||||
|
|||||||
@@ -436,6 +436,9 @@ class RetryMW:
|
|||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
cost=None,
|
cost=None,
|
||||||
usage_source=result.usage_source,
|
usage_source=result.usage_source,
|
||||||
|
cached_prompt_tokens=result.cached_prompt_tokens,
|
||||||
|
model_reported=result.model_reported,
|
||||||
|
reasoning_tokens=result.reasoning_tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from polygateway.errors import GatewayUnavailableError, GovernanceBackendError
|
from polygateway.errors import GatewayUnavailableError, GovernanceBackendError
|
||||||
from polygateway.middleware.cache import digest_messages
|
from polygateway.middleware.cache import digest_messages
|
||||||
|
from polygateway.types import canonical_sampling_json, merge_sampling
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -28,7 +29,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 18 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||||
self._recorder = recorder
|
self._recorder = recorder
|
||||||
@@ -61,6 +62,11 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
error=error,
|
||||||
|
cached_prompt_tokens=response.cached_prompt_tokens if response else None,
|
||||||
|
model_reported=response.model_reported if response else None,
|
||||||
|
reasoning_tokens=response.reasoning_tokens if response else None,
|
||||||
|
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||||
|
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||||
@@ -81,6 +87,14 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
error=None,
|
error=None,
|
||||||
|
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
||||||
|
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
||||||
|
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||||
|
model_reported=response.model_reported,
|
||||||
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
|
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||||
|
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||||
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_terminal_failure(
|
async def emit_terminal_failure(
|
||||||
@@ -103,6 +117,11 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
error=error,
|
error=error,
|
||||||
|
cached_prompt_tokens=None,
|
||||||
|
model_reported=None,
|
||||||
|
reasoning_tokens=None,
|
||||||
|
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||||
|
sampling=canonical_sampling_json(request.sampling),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _record(
|
async def _record(
|
||||||
@@ -123,6 +142,10 @@ class TelemetryEmitter:
|
|||||||
max_inter_token_ms: float | None,
|
max_inter_token_ms: float | None,
|
||||||
cache_hit: bool,
|
cache_hit: bool,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
|
cached_prompt_tokens: int | None,
|
||||||
|
model_reported: str | None,
|
||||||
|
sampling: str | None,
|
||||||
|
reasoning_tokens: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -134,7 +157,9 @@ class TelemetryEmitter:
|
|||||||
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||||
cost = None
|
cost = None
|
||||||
elif error is None and model and self._pricing is not None:
|
elif error is None and model and self._pricing is not None:
|
||||||
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
cost = self._pricing.cost(
|
||||||
|
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
cost = None
|
cost = None
|
||||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||||
@@ -158,6 +183,10 @@ class TelemetryEmitter:
|
|||||||
cache_hit=cache_hit,
|
cache_hit=cache_hit,
|
||||||
error=error,
|
error=error,
|
||||||
cost=cost,
|
cost=cost,
|
||||||
|
cached_prompt_tokens=cached_prompt_tokens,
|
||||||
|
model_reported=model_reported,
|
||||||
|
sampling=sampling,
|
||||||
|
reasoning_tokens=reasoning_tokens,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from polygateway.types import (
|
|||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
Usage,
|
Usage,
|
||||||
|
strip_unsupported_extra_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -113,7 +114,9 @@ class OcrClient:
|
|||||||
if quota_full not in ("wait", "fail_fast"):
|
if quota_full not in ("wait", "fail_fast"):
|
||||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
self._sources = list(sources)
|
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
||||||
|
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||||
|
self._sources = strip_unsupported_extra_body(list(sources), path="OCR")
|
||||||
self._selector = selector
|
self._selector = selector
|
||||||
self._feed_health = isinstance(selector, OutcomeAwareSelector)
|
self._feed_health = isinstance(selector, OutcomeAwareSelector)
|
||||||
self._quota = QuotaGate(limiter)
|
self._quota = QuotaGate(limiter)
|
||||||
|
|||||||
@@ -245,7 +245,11 @@ class StructuredOutputStrategy(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TelemetryRecorder(Protocol):
|
class TelemetryRecorder(Protocol):
|
||||||
"""遥测后端;18 字段冻结(M1 设计 §4.4),唯一调用点是 TelemetryEmitter。"""
|
"""遥测后端;20 字段冻结(M1 设计 §4.4 + issue #3),唯一调用点是 TelemetryEmitter。
|
||||||
|
|
||||||
|
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||||
|
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||||
|
"""
|
||||||
|
|
||||||
async def record_llm_call(
|
async def record_llm_call(
|
||||||
self,
|
self,
|
||||||
@@ -268,4 +272,8 @@ class TelemetryRecorder(Protocol):
|
|||||||
cache_hit: bool,
|
cache_hit: bool,
|
||||||
error: str | None,
|
error: str | None,
|
||||||
cost: float | None,
|
cost: float | None,
|
||||||
|
cached_prompt_tokens: int | None,
|
||||||
|
model_reported: str | None,
|
||||||
|
sampling: str | None,
|
||||||
|
reasoning_tokens: int | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**零内置单价**: 实验室走中转网关,计费非官方牌价;库内硬编码单价表
|
**零内置单价**: 实验室走中转网关,计费非官方牌价;库内硬编码单价表
|
||||||
必然过时并掩盖真实成本(P5 严禁默认值掩盖错误)。价格一律由使用方
|
必然过时并掩盖真实成本(P5 严禁默认值掩盖错误)。价格一律由使用方
|
||||||
提供——JSON 文件(`PGW_PRICING_PATH`)或 dict 注入;币种由使用方全表
|
提供——JSON 文件(`PGW_PRICING_PATH`)或 dict 注入;币种由使用方全表
|
||||||
统一口径,库不设币种字段(18 字段冻结)。查不到的 model → cost=None
|
统一口径,库不设币种字段(20 字段冻结)。查不到的 model → cost=None
|
||||||
且每 model 仅首次 warning(防日志风暴),不阻塞调用。
|
且每 model 仅首次 warning(防日志风暴),不阻塞调用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -22,22 +22,33 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ModelPrice:
|
class ModelPrice:
|
||||||
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。"""
|
"""每百万 token 的输入/输出单价(币种由使用方口径统一)。
|
||||||
|
|
||||||
|
`cached_input_per_1m` 是可选的**缓存读取单价**(issue #3): 供应商 prompt
|
||||||
|
cache 命中的那部分输入按更低单价计费。不填即不启用——库绝不按经验折扣率
|
||||||
|
猜一个数(P5 严禁默认值掩盖),未填时全额按 `input_per_1m` 计。
|
||||||
|
"""
|
||||||
|
|
||||||
input_per_1m: float
|
input_per_1m: float
|
||||||
output_per_1m: float
|
output_per_1m: float
|
||||||
|
cached_input_per_1m: float | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.input_per_1m < 0 or self.output_per_1m < 0:
|
if self.input_per_1m < 0 or self.output_per_1m < 0:
|
||||||
raise ValueError("单价不能为负")
|
raise ValueError("单价不能为负")
|
||||||
|
if self.cached_input_per_1m is not None and self.cached_input_per_1m < 0:
|
||||||
|
raise ValueError("缓存读取单价不能为负")
|
||||||
|
|
||||||
|
|
||||||
class PricingTable:
|
class PricingTable:
|
||||||
"""model → 单价 的只读表;cost() 是全库唯一换算点(经 TelemetryEmitter)。"""
|
"""model → 单价 的只读表;cost() 有两个调用点: `TelemetryEmitter`(chat 主路径)
|
||||||
|
与 `embedding.py` 的批量换算。"""
|
||||||
|
|
||||||
def __init__(self, prices: Mapping[str, ModelPrice]) -> None:
|
def __init__(self, prices: Mapping[str, ModelPrice]) -> None:
|
||||||
self._prices = dict(prices)
|
self._prices = dict(prices)
|
||||||
self._warned: set[str] = set()
|
self._warned: set[str] = set()
|
||||||
|
# 独立集合: 与"未知 model"的告警去重键分开,避免 model 名恰好撞上时互相抑制
|
||||||
|
self._warned_clamp: set[str] = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, path: Path | str) -> PricingTable:
|
def from_file(cls, path: Path | str) -> PricingTable:
|
||||||
@@ -53,21 +64,61 @@ class PricingTable:
|
|||||||
for model, entry in data.items():
|
for model, entry in data.items():
|
||||||
if not isinstance(entry, dict) or not {"input_per_1m", "output_per_1m"} <= set(entry):
|
if not isinstance(entry, dict) or not {"input_per_1m", "output_per_1m"} <= set(entry):
|
||||||
raise ValueError(f"价格表 {p} 条目 {model!r} 须含 input_per_1m 与 output_per_1m")
|
raise ValueError(f"价格表 {p} 条目 {model!r} 须含 input_per_1m 与 output_per_1m")
|
||||||
|
cached_raw = entry.get("cached_input_per_1m")
|
||||||
|
try:
|
||||||
|
cached = None if cached_raw is None else float(cached_raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 必须是数字: {cached_raw!r}"
|
||||||
|
) from exc
|
||||||
|
if cached is not None and cached < 0:
|
||||||
|
raise ValueError(f"价格表 {p} 条目 {model!r} 的 cached_input_per_1m 不能为负")
|
||||||
prices[model] = ModelPrice(
|
prices[model] = ModelPrice(
|
||||||
input_per_1m=float(entry["input_per_1m"]),
|
input_per_1m=float(entry["input_per_1m"]),
|
||||||
output_per_1m=float(entry["output_per_1m"]),
|
output_per_1m=float(entry["output_per_1m"]),
|
||||||
|
cached_input_per_1m=cached,
|
||||||
)
|
)
|
||||||
return cls(prices)
|
return cls(prices)
|
||||||
|
|
||||||
def cost(self, model: str, prompt_tokens: int, completion_tokens: int) -> float | None:
|
def cost(
|
||||||
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。"""
|
self,
|
||||||
|
model: str,
|
||||||
|
prompt_tokens: int,
|
||||||
|
completion_tokens: int,
|
||||||
|
cached_prompt_tokens: int | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""换算一次调用成本;未知 model 记 None 并仅首次 warning。
|
||||||
|
|
||||||
|
`cached_prompt_tokens` 是供应商 prompt cache 命中的输入 token 数
|
||||||
|
(issue #3);仅当该 model 配了 `cached_input_per_1m` 时才分段计价,
|
||||||
|
否则全额按输入价——不猜折扣率。参数带默认值: embedding 侧的三参调用
|
||||||
|
形态不受影响。
|
||||||
|
"""
|
||||||
price = self._prices.get(model)
|
price = self._prices.get(model)
|
||||||
if price is None:
|
if price is None:
|
||||||
if model not in self._warned:
|
if model not in self._warned:
|
||||||
self._warned.add(model)
|
self._warned.add(model)
|
||||||
logger.warning("pricing 表无 model {!r} 的单价,cost 记 None", model)
|
logger.warning("pricing 表无 model {!r} 的单价,cost 记 None", model)
|
||||||
return None
|
return None
|
||||||
return (
|
billed_input = prompt_tokens / 1_000_000 * price.input_per_1m
|
||||||
prompt_tokens / 1_000_000 * price.input_per_1m
|
# 负数按"无命中"处理: cost() 是公共方法,不能假定调用方已过 transport 的校验
|
||||||
+ completion_tokens / 1_000_000 * price.output_per_1m
|
if price.cached_input_per_1m is not None and (cached_prompt_tokens or 0) > 0:
|
||||||
|
cached = self._clamp_cached(model, prompt_tokens, cached_prompt_tokens)
|
||||||
|
billed_input = (prompt_tokens - cached) / 1_000_000 * price.input_per_1m + (
|
||||||
|
cached / 1_000_000 * price.cached_input_per_1m
|
||||||
)
|
)
|
||||||
|
return billed_input + completion_tokens / 1_000_000 * price.output_per_1m
|
||||||
|
|
||||||
|
def _clamp_cached(self, model: str, prompt_tokens: int, cached: int) -> int:
|
||||||
|
"""命中数按输入总数夹取: 网关口径异常不得算出负成本(每 model 只警告一次)。"""
|
||||||
|
if cached <= prompt_tokens:
|
||||||
|
return cached
|
||||||
|
if model not in self._warned_clamp:
|
||||||
|
self._warned_clamp.add(model)
|
||||||
|
logger.warning(
|
||||||
|
"model {!r} 上报的缓存命中 {} 超过输入总数 {},按总数夹取计价",
|
||||||
|
model,
|
||||||
|
cached,
|
||||||
|
prompt_tokens,
|
||||||
|
)
|
||||||
|
return prompt_tokens
|
||||||
|
|||||||
+175
-10
@@ -10,20 +10,37 @@ from dataclasses import dataclass
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderProfile:
|
class ProviderProfile:
|
||||||
"""单个 provider 的能力与差异声明。
|
"""单个 provider 的能力与差异声明。
|
||||||
|
|
||||||
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
thinking_on/thinking_off 分别是 `SourceConfig.enable_thinking` 为
|
||||||
True/False 时并入请求体的参数片段(None 时二者都不注入,用模型默认);
|
True/False 时并入请求体的参数片段(`enable_thinking` 为 None 时二者都不
|
||||||
strip_think_tags 声明响应 content 需剥离 ``<think>`` 标签(qwen 系);
|
注入,用模型默认);strip_think_tags 声明响应 content 需剥离 ``<think>``
|
||||||
supports_native_schema 供 D14 阶梯选择原生 response_format 策略。
|
标签(qwen 系);supports_native_schema 供 D14 阶梯选择原生 response_format。
|
||||||
|
|
||||||
|
两档各有三种取值,**语义互不重叠**(issue #5):
|
||||||
|
|
||||||
|
========== ==========================================================
|
||||||
|
``{...}`` 已知的注入片段
|
||||||
|
``{}`` 已知**无需注入**任何参数即处于该档
|
||||||
|
``None`` **未知**: 本库不知道该 provider 如何表达这一档
|
||||||
|
========== ==========================================================
|
||||||
|
|
||||||
|
`None` 与 `{}` 必须分开: 二者曾同为空字典,导致 `enable_thinking=False`
|
||||||
|
对 minimax/openai 源静默失效——调用方以为关掉了推理,实际什么都没发生。
|
||||||
|
现在 `None` 会在装配期显式报错并指路 `register_provider` / `extra_body`。
|
||||||
|
|
||||||
|
注: 本类只声明**形态**(参数长什么样,按 provider 变);某个具体模型能否
|
||||||
|
关闭推理属**能力**(按 model 变),见 `ThinkingCapability`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
thinking_on: dict[str, Any]
|
thinking_on: Mapping[str, Any] | None
|
||||||
thinking_off: dict[str, Any]
|
thinking_off: Mapping[str, Any] | None
|
||||||
strip_think_tags: bool
|
strip_think_tags: bool
|
||||||
supports_native_schema: bool = False
|
supports_native_schema: bool = False
|
||||||
|
|
||||||
@@ -43,23 +60,171 @@ DEFAULT_PROFILES: Mapping[str, ProviderProfile] = MappingProxyType(
|
|||||||
thinking_off={"thinking": {"type": "disabled"}},
|
thinking_off={"thinking": {"type": "disabled"}},
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
|
# OpenAI 兼容基线段名: 实践中被复用为**任意**兼容厂商的兜底(下游把
|
||||||
|
# kimi-k3 挂在 provider=openai 下),故不能下发任何厂商方言参数——发给
|
||||||
|
# 不认识它的厂商会 400。两档标 None(未知): 配了 enable_thinking 即在
|
||||||
|
# 装配期报错并指路,真 OpenAI 推理模型的用户走 register_provider
|
||||||
"openai": ProviderProfile(
|
"openai": ProviderProfile(
|
||||||
name="openai",
|
name="openai",
|
||||||
thinking_on={},
|
thinking_on=None,
|
||||||
thinking_off={},
|
thinking_off=None,
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
# OpenAI 兼容基线,无已知注入差异;reasoning_content 由 transport 通用处理
|
# 注入形态出处: 2026-08-02 经自建 new-api 中转实测(findings §2),
|
||||||
|
# **直连官方端点未验证**。实测 enable_thinking / thinking 两种写法均被
|
||||||
|
# 静默丢弃(prompt_tokens 恒定不变),reasoning_effort 才是真开关。
|
||||||
|
# "开"取 medium: qwen 的 enable_thinking:true 与 deepseek 的
|
||||||
|
# thinking:{enabled} 都不指定预算、由模型自定,medium 是五档里语义最接近
|
||||||
|
# "厂商正常强度"的一档;取 high 等于替下游做"加钱换质量"的业务判断。
|
||||||
|
# 要精确控制档位经 `SourceConfig.extra_body`(优先级高于本片段)
|
||||||
"minimax": ProviderProfile(
|
"minimax": ProviderProfile(
|
||||||
name="minimax",
|
name="minimax",
|
||||||
thinking_on={},
|
thinking_on={"reasoning_effort": "medium"},
|
||||||
thinking_off={},
|
thinking_off={"reasoning_effort": "none"},
|
||||||
strip_think_tags=False,
|
strip_think_tags=False,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ThinkingUnsupportedError(ValueError):
|
||||||
|
"""推理开关无法满足: 形态未知或该模型不支持该方向(issue #5)。
|
||||||
|
|
||||||
|
是 `ValueError` 的子类而非 `errors.py` 四分类之一——它描述的是**配置**
|
||||||
|
不可满足(装配期就该炸),不是一次调用的运行时失败。transport 在请求期
|
||||||
|
捕获它并翻译为 `RequestRejectedError` 再进四分类。单列一个类型是为了让
|
||||||
|
捕获点能精确到它,而不是宽catch 整个 `ValueError`(那会把序列化等无关
|
||||||
|
错误误贴成"推理开关无法满足")。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThinkingCapability:
|
||||||
|
"""某个**具体模型**能否关闭推理(issue #5);登记必须附实测证据与日期。
|
||||||
|
|
||||||
|
与 `ProviderProfile` 的分工: 后者声明**形态**(参数长什么样,按 provider 变,
|
||||||
|
数年不变一次),本类声明**能力**(按 model 变,同一 provider 每代都变)。二者
|
||||||
|
合一在 provider 级表达不了代际差异——实测 MiniMax-M3 可关闭推理,而同厂的
|
||||||
|
M2.7/M2.5 三种参数形态全部无效(findings §2.3),profile 一格管不住三个模型。
|
||||||
|
|
||||||
|
`evidence` 不是装饰: 能力表过期是必然事件,没有出处就无从判断该不该信它。
|
||||||
|
"""
|
||||||
|
|
||||||
|
can_disable: bool
|
||||||
|
evidence: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CAPABILITIES: Mapping[str, ThinkingCapability] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"MiniMax-M3": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 经 new-api 中转实测 N=10: reasoning_effort=none 稳定关闭,零跳变",
|
||||||
|
),
|
||||||
|
"MiniMax-M2.7": ThinkingCapability(
|
||||||
|
can_disable=False,
|
||||||
|
evidence=(
|
||||||
|
"2026-08-02 实测 reasoning_effort=none / thinking:{disabled} / thinking:{adaptive} "
|
||||||
|
"各 N=3 全部无效;OpenRouter 注册表登记 mandatory:true,models.dev 登记无控制手段"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"MiniMax-M2.5": ThinkingCapability(
|
||||||
|
can_disable=False,
|
||||||
|
evidence="2026-08-02 实测同 M2.7: 三种形态各 N=3 全部无效;外部注册表同样登记为强制推理",
|
||||||
|
),
|
||||||
|
"qwen3.7-plus": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 实测 enable_thinking=false 关闭(completion 5 token,无推理)",
|
||||||
|
),
|
||||||
|
"deepseek-v4-pro": ThinkingCapability(
|
||||||
|
can_disable=True,
|
||||||
|
evidence="2026-08-02 实测 thinking:{type:disabled} 关闭(completion 3 token,无推理)",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""在用模型的推理能力登记(YAGNI: 不覆盖全世界,未登记走 `resolve_thinking` 退化)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_capability(
|
||||||
|
model: str, *, table: Mapping[str, ThinkingCapability] | None = None
|
||||||
|
) -> ThinkingCapability | None:
|
||||||
|
"""按模型名精确查找;未登记返回 None(= 能力未知,由调用方决定如何退化)。
|
||||||
|
|
||||||
|
与 `get_provider` 未注册即报错不同: provider 是配置里写死的少数几个值,
|
||||||
|
写错就是配置错误;而模型名千变万化,新模型上线不该被库挡住(设计 §5 R4)。
|
||||||
|
"""
|
||||||
|
return (DEFAULT_CAPABILITIES if table is None else table).get(model)
|
||||||
|
|
||||||
|
|
||||||
|
def register_capability(
|
||||||
|
model: str,
|
||||||
|
capability: ThinkingCapability,
|
||||||
|
*,
|
||||||
|
base: Mapping[str, ThinkingCapability] | None = None,
|
||||||
|
) -> dict[str, ThinkingCapability]:
|
||||||
|
"""纯函数注册: 返回 base(缺省 DEFAULT_CAPABILITIES)+ 新条目的新表,同名覆盖。"""
|
||||||
|
table = dict(DEFAULT_CAPABILITIES if base is None else base)
|
||||||
|
table[model] = capability
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_thinking(
|
||||||
|
profile: ProviderProfile,
|
||||||
|
capability: ThinkingCapability | None,
|
||||||
|
enable_thinking: bool | None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
warn_unregistered: bool = True,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""三态 + 两层能力 → 请求体注入片段;不可满足时 ValueError。
|
||||||
|
|
||||||
|
调用点负责翻译: 装配期直接冒泡(配置错误),transport 内翻译为
|
||||||
|
`RequestRejectedError`(四分类之一)。判定顺序即语义,不可调换——形态未知时
|
||||||
|
无从注入,能力如何无关紧要,故 Phase 2 必须先于 Phase 4;未登记模型没有
|
||||||
|
`can_disable` 可读,故 Phase 3 必须先于 Phase 4。
|
||||||
|
|
||||||
|
`model` 只用于错误与告警文案: 报错能定位到具体模型才有可操作性,而
|
||||||
|
`capability` 为 None(未登记)时无从从别处取得模型名。
|
||||||
|
|
||||||
|
`warn_unregistered=False` 供请求热路径去重用: 装配期已经喊过一次,逐次
|
||||||
|
调用再喊只会刷屏。判定结果不受此参数影响。
|
||||||
|
"""
|
||||||
|
# Phase 1: 调用方不表态 —— 与 False 严格区分,用模型默认档
|
||||||
|
if enable_thinking is None:
|
||||||
|
return {}
|
||||||
|
slot = profile.thinking_on if enable_thinking else profile.thinking_off
|
||||||
|
direction = "thinking_on" if enable_thinking else "thinking_off"
|
||||||
|
# Phase 2: 形态未知 —— 提供了开关却不知道怎么发,静默放行就是欺骗调用方
|
||||||
|
if slot is None:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"provider {profile.name!r} 的 {direction} 形态未知(模型 {model!r}): "
|
||||||
|
f"本库不知道该 provider 如何表达这一档。请用 register_provider 注册形态,"
|
||||||
|
f"或改用 SourceConfig.extra_body 直接下发供应商参数"
|
||||||
|
)
|
||||||
|
# Phase 3: 能力未登记 —— 新模型上线不该被库挡住,但也不该假装成功
|
||||||
|
if capability is None:
|
||||||
|
if warn_unregistered:
|
||||||
|
_warn_unregistered(model, profile, slot)
|
||||||
|
return slot
|
||||||
|
# Phase 4: 明确不支持关闭 —— 调用方要的是"不推理"的语义保证,给不了必须说
|
||||||
|
if enable_thinking is False and not capability.can_disable:
|
||||||
|
raise ThinkingUnsupportedError(
|
||||||
|
f"模型 {model!r} 无法关闭推理,enable_thinking=False 无法满足: "
|
||||||
|
f"{capability.evidence}。该模型的推理是固有属性,任何参数都关不掉——"
|
||||||
|
f"需要关闭思维链请换用支持关闭的模型"
|
||||||
|
)
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unregistered(model: str, profile: ProviderProfile, slot: Mapping[str, Any]) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"模型 {} 的推理能力未登记,按 provider {} 的形态尽力注入 {};"
|
||||||
|
"若该模型实际不支持这一档,本次设置将静默失效。实测后请用 register_capability 登记",
|
||||||
|
model,
|
||||||
|
profile.name,
|
||||||
|
dict(slot),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_provider(
|
def get_provider(
|
||||||
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
name: str, *, registry: Mapping[str, ProviderProfile] | None = None
|
||||||
) -> ProviderProfile:
|
) -> ProviderProfile:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
||||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||||
构造不连库(lazy),18 列 schema 与 SQLite 版同名同序。
|
构造不连库(lazy),20 列 schema 与 SQLite 版同名同序。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -39,10 +39,28 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
cost DOUBLE PRECISION,
|
cost DOUBLE PRECISION,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
cached_prompt_tokens INTEGER,
|
||||||
|
model_reported TEXT,
|
||||||
|
sampling TEXT,
|
||||||
|
reasoning_tokens INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
|
||||||
|
_BACKFILL = (
|
||||||
|
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
||||||
|
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||||
|
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
||||||
|
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析)
|
||||||
|
_EXISTING_COLUMNS = (
|
||||||
|
"SELECT attname FROM pg_attribute "
|
||||||
|
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||||
|
)
|
||||||
|
|
||||||
_COLUMNS = (
|
_COLUMNS = (
|
||||||
"call_id",
|
"call_id",
|
||||||
"parent_call_id",
|
"parent_call_id",
|
||||||
@@ -62,6 +80,10 @@ _COLUMNS = (
|
|||||||
"cache_hit",
|
"cache_hit",
|
||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
@@ -104,6 +126,7 @@ class PostgresRecorder:
|
|||||||
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
||||||
async with self._pool.acquire() as conn:
|
async with self._pool.acquire() as conn:
|
||||||
await conn.execute(_DDL)
|
await conn.execute(_DDL)
|
||||||
|
await self._backfill_columns(conn)
|
||||||
self._schema_ready = True
|
self._schema_ready = True
|
||||||
return self._pool
|
return self._pool
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -113,6 +136,29 @@ class PostgresRecorder:
|
|||||||
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
logger.warning("Postgres 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _backfill_columns(self, conn: object) -> None:
|
||||||
|
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
||||||
|
|
||||||
|
两条纪律各有实测理由:
|
||||||
|
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
||||||
|
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||||
|
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||||
|
(SQLite 侧同款守卫,两侧必须对称)。
|
||||||
|
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
||||||
|
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
||||||
|
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||||
|
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||||
|
for column, statement in _BACKFILL:
|
||||||
|
if column not in existing:
|
||||||
|
await conn.execute(statement) # type: ignore[attr-defined]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
||||||
pool = await self._ensure_ready()
|
pool = await self._ensure_ready()
|
||||||
|
|||||||
@@ -34,10 +34,23 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
cost REAL,
|
cost REAL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
cached_prompt_tokens INTEGER,
|
||||||
|
model_reported TEXT,
|
||||||
|
sampling TEXT,
|
||||||
|
reasoning_tokens INTEGER
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||||
|
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||||
|
_BACKFILL_COLUMNS = (
|
||||||
|
("cached_prompt_tokens", "INTEGER"),
|
||||||
|
("model_reported", "TEXT"),
|
||||||
|
("sampling", "TEXT"),
|
||||||
|
("reasoning_tokens", "INTEGER"),
|
||||||
|
)
|
||||||
|
|
||||||
_COLUMNS = (
|
_COLUMNS = (
|
||||||
"call_id",
|
"call_id",
|
||||||
"parent_call_id",
|
"parent_call_id",
|
||||||
@@ -57,6 +70,10 @@ _COLUMNS = (
|
|||||||
"cache_hit",
|
"cache_hit",
|
||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = (
|
_INSERT = (
|
||||||
@@ -82,9 +99,37 @@ class SQLiteRecorder:
|
|||||||
self._conn = conn
|
self._conn = conn
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||||
|
self._backfill_columns()
|
||||||
|
|
||||||
|
def _backfill_columns(self) -> None:
|
||||||
|
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||||
|
|
||||||
|
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||||
|
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||||
|
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||||
|
比逐行丢弃严重得多。
|
||||||
|
"""
|
||||||
|
if self._conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
||||||
|
return
|
||||||
|
for column, decl in _BACKFILL_COLUMNS:
|
||||||
|
if column in existing:
|
||||||
|
continue
|
||||||
|
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
|
||||||
|
try:
|
||||||
|
self._conn.execute(f"ALTER TABLE llm_calls ADD COLUMN {column} {decl}")
|
||||||
|
self._conn.commit()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
# duplicate column: 多进程共库时后到者必然撞上,属预期竞态,视为成功
|
||||||
|
if "duplicate column" not in str(exc).lower():
|
||||||
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 18 字段冻结签名(ports.TelemetryRecorder)。"""
|
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in _COLUMNS)
|
row = tuple(fields[col] for col in _COLUMNS)
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.providers import ProviderProfile, get_provider
|
from polygateway.providers import (
|
||||||
|
ProviderProfile,
|
||||||
|
ThinkingCapability,
|
||||||
|
ThinkingUnsupportedError,
|
||||||
|
get_capability,
|
||||||
|
get_provider,
|
||||||
|
resolve_thinking,
|
||||||
|
)
|
||||||
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||||
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import EmbeddingTransportResult, SourceConfig, TransportResult
|
||||||
|
|
||||||
@@ -45,6 +52,12 @@ def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool,
|
|||||||
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
||||||
if chunk.get("usage"):
|
if chunk.get("usage"):
|
||||||
usage_sink["usage"] = chunk["usage"]
|
usage_sink["usage"] = chunk["usage"]
|
||||||
|
if "model" not in usage_sink:
|
||||||
|
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死
|
||||||
|
# sink——否则后续真实版本会丢(issue #3)
|
||||||
|
reported = _coerce_model_reported(chunk.get("model"))
|
||||||
|
if reported is not None:
|
||||||
|
usage_sink["model"] = reported
|
||||||
choices = chunk.get("choices") or []
|
choices = chunk.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
return None
|
return None
|
||||||
@@ -152,6 +165,54 @@ def _resolve_usage(usage: dict[str, Any]) -> tuple[int, int, str]:
|
|||||||
return 0, 0, "unavailable"
|
return 0, 0, "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_cached_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.prompt_tokens_details.cached_tokens(issue #3);形态异常一律 None。
|
||||||
|
|
||||||
|
`0` 与 `None` 必须可区分: 前者是"该源上报了一次真实零命中",后者是"该源
|
||||||
|
不报这个数",下游对两者的处置不同(后者不可做缓存成本校正)。故只把
|
||||||
|
**负数与非整数**归 None,`0` 如实保留。`bool` 显式排除——isinstance(True, int)
|
||||||
|
在 Python 里为真,放行会把 `True` 记成 1 个命中 token。
|
||||||
|
"""
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
details = usage.get("prompt_tokens_details")
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None
|
||||||
|
cached = details.get("cached_tokens")
|
||||||
|
if isinstance(cached, bool) or not isinstance(cached, int) or cached < 0:
|
||||||
|
return None
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_reasoning_tokens(usage: Any) -> int | None:
|
||||||
|
"""取 usage.completion_tokens_details.reasoning_tokens(issue #6);形态异常一律 None。
|
||||||
|
|
||||||
|
与 `_coerce_cached_tokens` 逐条同构(两者是 OpenAI 兼容 usage 里对称的一对):
|
||||||
|
`0` 如实保留、负数与非整数归 None、`bool` 显式排除。差别只在语义——本字段
|
||||||
|
的 None 是"**本次调用**未上报"而非"该源不上报": 中转在上游不返回 usage 时
|
||||||
|
会本地补算并整体替换 usage 对象,把 details 一并吃掉(findings §4c)。
|
||||||
|
"""
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
details = usage.get("completion_tokens_details")
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None
|
||||||
|
reasoning = details.get("reasoning_tokens")
|
||||||
|
if isinstance(reasoning, bool) or not isinstance(reasoning, int) or reasoning < 0:
|
||||||
|
return None
|
||||||
|
return reasoning
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_model_reported(value: Any) -> str | None:
|
||||||
|
"""取响应体的 model 字段(issue #3);非 str 或空白串一律 None,收口时去空白。
|
||||||
|
|
||||||
|
去空白不是洁癖: 下游拿这个串做实验快照的 key,`" m "` 与 `"m"` 会造成假分叉。
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
|
def _resolve_stream_usage(sink: dict[str, Any], salvaged: bool) -> tuple[int, int, str]:
|
||||||
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
|
"""流式用量口径: 打捞路径把 measured 降级为 estimated,unavailable 原样保留。
|
||||||
|
|
||||||
@@ -230,9 +291,14 @@ class OpenAICompatTransport:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
registry: Mapping[str, ProviderProfile] | None = None,
|
registry: Mapping[str, ProviderProfile] | None = None,
|
||||||
|
capabilities: Mapping[str, ThinkingCapability] | None = None,
|
||||||
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
|
self._capabilities = capabilities
|
||||||
|
# 未登记模型只喊一次: 装配期已喊过,逐次调用再喊是日志洪水。
|
||||||
|
# 实例级而非模块级 —— 模块级可变状态违反纯 asyncio 中立铁律
|
||||||
|
self._warned_models: set[str] = set()
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
@@ -255,10 +321,23 @@ class OpenAICompatTransport:
|
|||||||
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
|
payload: dict[str, Any] = {"model": source.model, "messages": messages, "stream": stream}
|
||||||
if stream:
|
if stream:
|
||||||
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
|
payload["stream_options"] = {"include_usage": True} # 强制 usage 帧(三项目同款)
|
||||||
if source.enable_thinking is True:
|
# 形态(provider 级)与能力(model 级)在此相遇;不可满足时 ValueError,
|
||||||
payload.update(profile.thinking_on)
|
# 由 complete() 翻译为四分类之一(issue #5)
|
||||||
elif source.enable_thinking is False:
|
capability = get_capability(source.model, table=self._capabilities)
|
||||||
payload.update(profile.thinking_off)
|
first_time = source.model not in self._warned_models
|
||||||
|
self._warned_models.add(source.model)
|
||||||
|
payload.update(
|
||||||
|
resolve_thinking(
|
||||||
|
profile,
|
||||||
|
capability,
|
||||||
|
source.enable_thinking,
|
||||||
|
model=source.model,
|
||||||
|
warn_unregistered=first_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 顺序即优先级(issue #4 设计决策 A): 配置级 extra_body 在前,调用级
|
||||||
|
# overlay(含结构化注入)在后覆盖之。两行不可调换
|
||||||
|
payload.update(source.extra_body)
|
||||||
payload.update(overlay)
|
payload.update(overlay)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -273,9 +352,18 @@ class OpenAICompatTransport:
|
|||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
|
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。"""
|
||||||
profile = get_provider(source.provider, registry=self._registry)
|
profile = get_provider(source.provider, registry=self._registry)
|
||||||
|
try:
|
||||||
payload = self._build_payload(
|
payload = self._build_payload(
|
||||||
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
|
messages=messages, source=source, profile=profile, stream=stream, overlay=overlay
|
||||||
)
|
)
|
||||||
|
except ThinkingUnsupportedError as exc:
|
||||||
|
# 推理开关不可满足是**请求本身**的问题: 换源重试都救不了它。只捕这个
|
||||||
|
# 专用类型而非宽 catch ValueError —— 后者会把序列化等无关错误误贴标签
|
||||||
|
raise RequestRejectedError(
|
||||||
|
f"{source.name} 推理开关无法满足: {exc}",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="chat",
|
||||||
|
) from exc
|
||||||
url = source.base_url.rstrip("/") + "/chat/completions"
|
url = source.base_url.rstrip("/") + "/chat/completions"
|
||||||
client = self._client_for(source)
|
client = self._client_for(source)
|
||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
@@ -360,6 +448,9 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=ttft_ms,
|
ttft_ms=ttft_ms,
|
||||||
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
|
model_reported=_coerce_model_reported(sink.get("model")),
|
||||||
|
reasoning_tokens=_coerce_reasoning_tokens(sink.get("usage")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _check_done(
|
||||||
@@ -442,6 +533,9 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
|
model_reported=_coerce_model_reported(body.get("model")),
|
||||||
|
reasoning_tokens=_coerce_reasoning_tokens(body.get("usage")),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -4,11 +4,27 @@
|
|||||||
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
_MISSING_DONE_DOMAIN = frozenset({"retry", "salvage"})
|
||||||
|
|
||||||
|
_PROTECTED_OVERLAY_KEYS: Mapping[str, str] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"model": "会让遥测记录的 model 与实际请求分叉,成本按错单价换算",
|
||||||
|
"messages": "会同时破坏缓存 key 与遥测的 messages 口径",
|
||||||
|
"stream": "会绕过流式活性看门狗(TTFT/inter-token 超时全部失效)",
|
||||||
|
"stream_options": "会丢 usage 帧,导致成本遥测归零、TPM 闸按预扣量结算失准",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""禁止出现在采样参数覆盖层里的键: 它们由治理层拥有,被覆盖即击穿治理。"""
|
||||||
|
|
||||||
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||||
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
|
"""usage_source 值域;仅约束库内生产侧取值,不在 frozen dataclass 上做运行时校验。"""
|
||||||
|
|
||||||
@@ -16,6 +32,45 @@ _EST_TOKENS_QUOTA_DIVISOR = 60
|
|||||||
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
||||||
|
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
|
||||||
|
|
||||||
|
两类校验缺一不可(issue #4 设计决策 B):保护键会击穿治理;不可 JSON
|
||||||
|
序列化的值会在 `CacheMW` 的降级 try **之外**抛裸 `TypeError`——那条路径
|
||||||
|
不属错误四分类、`TelemetryMW` 也不捕,结果是一行遥测都没有就崩了。
|
||||||
|
两者都在进洋葱之前收口,故抛裸 `ValueError`(调用方编程错误,不可重试)。
|
||||||
|
"""
|
||||||
|
# Phase 1: 键形态——必须先于序列化试探,否则非 str 键会因 sort_keys 的
|
||||||
|
# 比较失败被误报成"值不可序列化",把人指向错误的方向
|
||||||
|
for key in overlay:
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ValueError(f"{origin} 的键必须是 str: {key!r}(canonical JSON 要求)")
|
||||||
|
# Phase 2: 保护键
|
||||||
|
for key, reason in _PROTECTED_OVERLAY_KEYS.items():
|
||||||
|
if key in overlay:
|
||||||
|
raise ValueError(f"{origin} 不得覆盖 {key!r}: {reason}")
|
||||||
|
# Phase 3: 值可序列化(缓存 key 与遥测列都要 json.dumps)
|
||||||
|
try:
|
||||||
|
json.dumps(dict(overlay), sort_keys=True, ensure_ascii=False)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"{origin} 的值必须可 JSON 序列化(如 numpy 标量请先转 float/int): {exc}"
|
||||||
|
) from exc
|
||||||
|
return dict(overlay)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
|
||||||
|
return {**extra_body, **sampling}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
|
||||||
|
"""缓存 key 与遥测 sampling 列共用的序列化口径;空 mapping → None。"""
|
||||||
|
if not merged:
|
||||||
|
return None
|
||||||
|
return json.dumps(dict(merged), sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||||
@@ -30,12 +85,29 @@ class LLMResponse:
|
|||||||
ttft_ms: float | None
|
ttft_ms: float | None
|
||||||
max_inter_token_ms: float | None
|
max_inter_token_ms: float | None
|
||||||
cache_hit: bool
|
cache_hit: bool
|
||||||
|
"""**PolyGateway 自身响应缓存**命中(未产生网关调用);与供应商侧 prompt
|
||||||
|
cache 无关,后者见 `cached_prompt_tokens`。"""
|
||||||
call_id: str
|
call_id: str
|
||||||
# —— 库新增(只增不删,必带默认值;迁移兼容硬约束)——
|
# —— 库新增(只增不删,必带默认值;迁移兼容硬约束)——
|
||||||
source_name: str = ""
|
source_name: str = ""
|
||||||
cost: float | None = None
|
cost: float | None = None
|
||||||
usage_source: str = "measured"
|
usage_source: str = "measured"
|
||||||
structured_data: Any | None = None
|
structured_data: Any | None = None
|
||||||
|
cached_prompt_tokens: int | None = None
|
||||||
|
"""供应商 prompt cache 命中的输入 token 数(issue #3);None = 该源未上报,
|
||||||
|
与"上报了但是 0"(真实零命中)区分——两者对下游的处置不同。"""
|
||||||
|
model_reported: str | None = None
|
||||||
|
"""API 响应体里的 model 字段;None = 未上报。与 `model`(配置别名)可能
|
||||||
|
分叉——供应商把别名指向新权重时,实验复现必须认这个串。"""
|
||||||
|
reasoning_tokens: int | None = None
|
||||||
|
"""推理消耗的输出 token 数(含在 `completion_tokens` 内,故不影响成本总额,
|
||||||
|
只补归因;issue #6)。
|
||||||
|
|
||||||
|
`None` = **本次调用**未上报,**不是**"该源不上报"——中转网关在上游不返回
|
||||||
|
usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把
|
||||||
|
`completion_tokens_details` 一并吃掉(findings §4c 实测同一请求 10 轮呈
|
||||||
|
6:4 双峰)。实测三家供应商在未推理时都是整个 details 缺失、无人上报 `0`,
|
||||||
|
故下游判据须为 `in (None, 0)`,写 `== 0` 的条件永远不成立。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -50,6 +122,12 @@ class ChatRequest:
|
|||||||
structured: Any | None = None
|
structured: Any | None = None
|
||||||
stream: bool = True
|
stream: bool = True
|
||||||
overlay: dict[str, Any] = field(default_factory=dict)
|
overlay: dict[str, Any] = field(default_factory=dict)
|
||||||
|
sampling: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
"""调用方采样意图的快照,库内中间件**永不修改**(issue #4 设计决策 A)。
|
||||||
|
|
||||||
|
与 `overlay` 分开是因为后者会被结构化中间件注入 `response_format`,在洋葱
|
||||||
|
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
|
||||||
|
同一列在不同行口径分叉。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -82,6 +160,10 @@ class TransportResult:
|
|||||||
ttft_ms: float | None
|
ttft_ms: float | None
|
||||||
max_inter_token_ms: float | None
|
max_inter_token_ms: float | None
|
||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
# —— 可观测字段(issue #3/#6;带默认值,非 OpenAI 兼容的 transport 可不填)——
|
||||||
|
cached_prompt_tokens: int | None = None
|
||||||
|
model_reported: str | None = None
|
||||||
|
reasoning_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -107,11 +189,18 @@ class SourceConfig:
|
|||||||
enable_thinking: bool | None = None
|
enable_thinking: bool | None = None
|
||||||
missing_done: str = "retry"
|
missing_done: str = "retry"
|
||||||
trust_env: bool = True
|
trust_env: bool = True
|
||||||
|
extra_body: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
"""本源恒定的采样参数(如 `temperature=0`),并入请求体(issue #4)。
|
||||||
|
|
||||||
|
优先级低于调用级 overlay。注: 本字段令 `SourceConfig` 不再 hashable
|
||||||
|
(加任何 mapping 字段的固有代价,裸 dict 亦然),库内无以源作 key 的写法;
|
||||||
|
要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace`。"""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self._validate_identity()
|
self._validate_identity()
|
||||||
self._validate_gates()
|
self._validate_gates()
|
||||||
self._validate_watchdog()
|
self._validate_watchdog()
|
||||||
|
self._freeze_extra_body()
|
||||||
|
|
||||||
def effective_est_tokens(self) -> int:
|
def effective_est_tokens(self) -> int:
|
||||||
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
|
"""TPM 入场预扣量: 显式配置优先,否则按 tpm 派生(设计 §2.2)。"""
|
||||||
@@ -148,6 +237,39 @@ class SourceConfig:
|
|||||||
):
|
):
|
||||||
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
||||||
|
|
||||||
|
def _freeze_extra_body(self) -> None:
|
||||||
|
"""校验后转只读视图: 装配完成的源不应再被就地改采样参数(设计决策 E)。"""
|
||||||
|
validated = validate_request_overlay(
|
||||||
|
self.extra_body, origin=f"SourceConfig({self.name}).extra_body"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "extra_body", MappingProxyType(validated))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_unsupported_extra_body(sources: list[SourceConfig], *, path: str) -> list[SourceConfig]:
|
||||||
|
"""剥离非 chat 路径不消费的 `extra_body` 并 warning(issue #4 决策 G)。
|
||||||
|
|
||||||
|
剥离是必需的而非顺手清理: embedding 的 payload 硬编码 `{model, input}`、
|
||||||
|
MonkeyOCR 只发 multipart 表单,两者都不会把 `extra_body` 发出去;但遥测的
|
||||||
|
`sampling` 列会并上 `source.extra_body`,不剥离就等于**记录一个从未发出的
|
||||||
|
参数**——那是数据造假,污染的恰是事后复现的唯一依据。
|
||||||
|
|
||||||
|
选择 warning 放行而非报错: 这两条路径本无采样语义,配错的后果远轻于 chat
|
||||||
|
路径,不值得让下游整个装配起不来(2026-07-31 人类拍板)。
|
||||||
|
"""
|
||||||
|
stripped = []
|
||||||
|
for source in sources:
|
||||||
|
if source.extra_body:
|
||||||
|
logger.warning(
|
||||||
|
"{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略"
|
||||||
|
"(需要 dimensions 等参数请提 issue): {}",
|
||||||
|
path,
|
||||||
|
source.name,
|
||||||
|
dict(source.extra_body),
|
||||||
|
)
|
||||||
|
source = dataclasses.replace(source, extra_body={})
|
||||||
|
stripped.append(source)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RetryPolicy:
|
class RetryPolicy:
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""真实 API 验证推理开关与 reasoning_tokens(issue #5 + #6)。
|
||||||
|
|
||||||
|
本组用例**必须真跑**: 改动的正确性与具体模型强相关,mock 只能验证代码路径,
|
||||||
|
验证不了"这个参数在这个模型上到底关没关掉推理"。
|
||||||
|
|
||||||
|
两条判据纪律(来自 findings §4c 的实测教训):
|
||||||
|
|
||||||
|
1. **判别量只能是 `reasoning_tokens`,不能是 `completion_tokens`。** 两档的输出
|
||||||
|
长度分布**是重叠的**: 实测关闭档最高 46 token(模型偶尔把解题过程写进正文),
|
||||||
|
开启档最低 13 token(medium 档想得少的那几轮),按长度阈值判两边都会误判。
|
||||||
|
而 `reasoning_tokens` 在同一批 30 轮里干净分开——关闭 15/15 为 None,
|
||||||
|
开启 15/15 大于 0。
|
||||||
|
2. **另配一个不含魔数的确定性锚点**(见 L2b): 同一模型上,关闭档的
|
||||||
|
`prompt_tokens` 严格小于开启档——供应商在开启时注入了推理指令,输入侧
|
||||||
|
token 数随之变大。这是相对比较,不硬编码任何具体数值。
|
||||||
|
3. **关闭方向要求每轮满足,开启方向只要求多数轮满足。** 中转在上游不返回
|
||||||
|
usage 时会本地补算并吃掉 `completion_tokens_details`(findings §4c),
|
||||||
|
开启方向因此可能偶尔观测不到;关闭方向不受影响。
|
||||||
|
|
||||||
|
源不可用一律 `skip` 并在报告中记为「未覆盖」,**绝不静默计入通过**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from polygateway.errors import (
|
||||||
|
AllSourcesExhausted,
|
||||||
|
RequestRejectedError,
|
||||||
|
SourceDeadError,
|
||||||
|
TransientError,
|
||||||
|
)
|
||||||
|
from polygateway.providers import DEFAULT_CAPABILITIES, get_capability
|
||||||
|
|
||||||
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
|
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
|
||||||
|
|
||||||
|
# slow: 本组 137 次真实调用、约 7 分钟,且判据是统计性的——网络抖动会让它偶发
|
||||||
|
# 失败(实测有一次 network_error 连续三次耗尽源)。让它阻断 `make ci` 会把测试
|
||||||
|
# 变成噪声源,故沿用项目既有的 slow 标记默认排除,合并前用 `-m slow` 显式真跑并
|
||||||
|
# 存档报告。"不自动门控"不等于"可跳过"。
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.slow,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(本组必须真跑)"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_OUT_DIR = Path("tests/outputs/e2e")
|
||||||
|
_ROUNDS = int(os.environ.get("PGW_E2E_THINKING_ROUNDS", "10"))
|
||||||
|
|
||||||
|
# 需要一点推理才能答对,但答案极短: 关掉推理时 completion 稳定在个位数,
|
||||||
|
# 开着时则是几百——两档之间隔着一个数量级,判据不必卡在噪声里
|
||||||
|
_PROMPT = "一个笼子里有若干鸡和兔,共 35 个头、94 只脚。鸡和兔各有多少只?只输出两个数字。"
|
||||||
|
|
||||||
|
_ON_MIN_COMPLETION = 100
|
||||||
|
"""仅用于 `reasoning_tokens` 被中转吃掉时的退路;关闭方向不设长度门(见 `_reasoning_off`)。"""
|
||||||
|
|
||||||
|
_ROWS: list[dict] = []
|
||||||
|
|
||||||
|
# 显式映射,不按模型名猜 provider —— 那正是 D11 要消灭的东西(providers.py 开篇)。
|
||||||
|
# 漏登记会被 test_every_capability_has_a_provider_mapping 当场抓住,而不是
|
||||||
|
# 在 L8 里被"源不可用"这个假理由吞掉
|
||||||
|
_MODEL_PROVIDER = {
|
||||||
|
"MiniMax-M3": "minimax",
|
||||||
|
"MiniMax-M2.7": "minimax",
|
||||||
|
"MiniMax-M2.5": "minimax",
|
||||||
|
"qwen3.7-plus": "qwen",
|
||||||
|
"deepseek-v4-pro": "deepseek",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_settings() -> GatewaySettings:
|
||||||
|
# 强制关缓存: 多轮测量要求每一轮都真的打到供应商,命中缓存会把后续轮次
|
||||||
|
# 变成对第一轮的回放,整组判据随之失效
|
||||||
|
return GatewaySettings.from_env("LLM", env={**_ENV, "PGW_CACHE_BACKEND": "none"})
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**source_overrides) -> GatewaySettings:
|
||||||
|
base = _base_settings()
|
||||||
|
source = dataclasses.replace(base.sources[0], **source_overrides)
|
||||||
|
return dataclasses.replace(base, sources=(source,))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_rounds(rounds: int, *, stream: bool = True, **source_overrides) -> list[dict]:
|
||||||
|
"""跑 N 轮真实调用,返回逐轮观测;任一轮抛错即向上冒泡由用例决定处置。"""
|
||||||
|
client = GatewayClient.from_settings(_settings(**source_overrides))
|
||||||
|
observations = []
|
||||||
|
try:
|
||||||
|
for i in range(rounds):
|
||||||
|
resp = await client.chat(
|
||||||
|
[{"role": "user", "content": _PROMPT}],
|
||||||
|
stream=stream,
|
||||||
|
# 每轮独立 salt: 即便某层缓存意外开着也不会回放
|
||||||
|
cache_salt=f"thinking-live-{i}",
|
||||||
|
)
|
||||||
|
observations.append(
|
||||||
|
{
|
||||||
|
"round": i + 1,
|
||||||
|
"prompt_tokens": resp.prompt_tokens,
|
||||||
|
"completion_tokens": resp.completion_tokens,
|
||||||
|
"reasoning_tokens": resp.reasoning_tokens,
|
||||||
|
"content": resp.content[:60],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
return observations
|
||||||
|
|
||||||
|
|
||||||
|
def _record(matrix_id: str, desc: str, status: str, detail, observations=None) -> None:
|
||||||
|
_ROWS.append(
|
||||||
|
{
|
||||||
|
"matrix": matrix_id,
|
||||||
|
"desc": desc,
|
||||||
|
"status": status,
|
||||||
|
"detail": detail,
|
||||||
|
"observations": observations or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_off(obs: dict) -> bool:
|
||||||
|
"""关闭方向: 只看 reasoning_tokens。
|
||||||
|
|
||||||
|
**刻意不设 completion_tokens 上限**: 实测关闭档偶尔会到 46 token(模型没照做
|
||||||
|
"只输出两个数字",把解题过程写进了正文),而那是正文不是推理。加长度门只会
|
||||||
|
把这种正常波动误判成"没关掉"。
|
||||||
|
"""
|
||||||
|
return obs["reasoning_tokens"] in (None, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_on(obs: dict) -> bool:
|
||||||
|
"""开启方向: 有 reasoning_tokens 就以它为准,它是本次改动引入的直接判据。
|
||||||
|
|
||||||
|
不能拿 completion_tokens 当开启方向的主判据: medium 档的推理量方差极大
|
||||||
|
(实测 15 轮跨 7-170 token),按长度阈值判会把"推理了但想得少"误判成没推理。
|
||||||
|
仅当中转吃掉了 ctd(reasoning_tokens is None)才退回长度判据。
|
||||||
|
"""
|
||||||
|
reasoning = obs["reasoning_tokens"]
|
||||||
|
if reasoning is not None:
|
||||||
|
return reasoning > 0
|
||||||
|
return obs["completion_tokens"] > _ON_MIN_COMPLETION
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_if_unreachable(exc: Exception, matrix_id: str, desc: str):
|
||||||
|
"""源不可用(渠道下线/模型未开通)→ 跳过并记为未覆盖,不伪装成通过。"""
|
||||||
|
_record(matrix_id, desc, "SKIP(源不可用)", str(exc)[:200])
|
||||||
|
pytest.skip(f"{matrix_id} 源不可用,已记为未覆盖: {str(exc)[:120]}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def _write_report():
|
||||||
|
yield
|
||||||
|
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
path = _OUT_DIR / f"test_thinking_live_{ts}.md"
|
||||||
|
lines = [
|
||||||
|
"# 推理开关与 reasoning_tokens 真实 API 验证",
|
||||||
|
"",
|
||||||
|
f"- 时间: {ts}",
|
||||||
|
f"- 每档轮数: {_ROUNDS}",
|
||||||
|
"- 关闭判据: **每轮** reasoning_tokens in (None, 0);刻意不设输出长度上限"
|
||||||
|
"(两档的 completion 分布重叠: 实测关闭档最高 46、开启档最低 13)",
|
||||||
|
f"- 开启判据: **多数轮** reasoning_tokens > 0(被中转吃掉时退回 completion > {_ON_MIN_COMPLETION})",
|
||||||
|
"- 确定性锚点(L2b): 关闭档 prompt_tokens 最大值 < 开启档最小值,相对比较无魔数",
|
||||||
|
"",
|
||||||
|
"## 矩阵结论",
|
||||||
|
"",
|
||||||
|
"| 矩阵 | 场景 | 结论 | 说明 |",
|
||||||
|
"|---|---|---|---|",
|
||||||
|
]
|
||||||
|
total_calls = 0
|
||||||
|
for row in _ROWS:
|
||||||
|
detail = str(row["detail"]).replace("|", "\\|").replace("\n", " ")[:160]
|
||||||
|
lines.append(f"| {row['matrix']} | {row['desc']} | {row['status']} | {detail} |")
|
||||||
|
total_calls += len(row["observations"])
|
||||||
|
lines += ["", f"**总真实调用次数: {total_calls}**", "", "## 逐轮原始观测", ""]
|
||||||
|
for row in _ROWS:
|
||||||
|
if not row["observations"]:
|
||||||
|
continue
|
||||||
|
lines += [f"### {row['matrix']} — {row['desc']}", "", "```json"]
|
||||||
|
lines.append(json.dumps(row["observations"], ensure_ascii=False, indent=2))
|
||||||
|
lines += ["```", ""]
|
||||||
|
uncovered = [r["matrix"] for r in _ROWS if r["status"].startswith("SKIP")]
|
||||||
|
if uncovered:
|
||||||
|
lines += ["## 未覆盖", "", f"以下矩阵行未跑到: {', '.join(uncovered)}", ""]
|
||||||
|
path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
print(f"\n[e2e 报告] {path}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMiniMaxM3:
|
||||||
|
"""M3 是唯一实测可关闭推理的 MiniMax 模型,修复的地基压在它身上。"""
|
||||||
|
|
||||||
|
async def test_l1_disable_actually_disables(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
_record(
|
||||||
|
"L1",
|
||||||
|
"enable_thinking=False(流式)",
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||||
|
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), f"关闭方向要求每轮满足: {obs}"
|
||||||
|
|
||||||
|
async def test_l2_enable_actually_enables(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
ons = [o for o in obs if _reasoning_on(o)]
|
||||||
|
_record(
|
||||||
|
"L2",
|
||||||
|
"enable_thinking=True(流式,注入 medium)",
|
||||||
|
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
||||||
|
f"{len(ons)}/{len(obs)} 轮观察到推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(ons) * 2 > len(obs), f"开启方向要求多数轮满足: {obs}"
|
||||||
|
|
||||||
|
async def test_l2b_off_and_on_are_distinguishable_without_magic_numbers(self):
|
||||||
|
"""确定性锚点: 开启档的 prompt_tokens 严格大于关闭档。
|
||||||
|
|
||||||
|
供应商在开启推理时会向模板注入推理指令,输入侧 token 数随之变大。这是
|
||||||
|
本组唯一不依赖输出侧噪声的证据,且是相对比较——不硬编码任何具体数值,
|
||||||
|
供应商改模板也不会让它假红。
|
||||||
|
"""
|
||||||
|
rounds = max(3, _ROUNDS // 3)
|
||||||
|
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
on = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
off_max = max(o["prompt_tokens"] for o in off)
|
||||||
|
on_min = min(o["prompt_tokens"] for o in on)
|
||||||
|
_record(
|
||||||
|
"L2b",
|
||||||
|
"关闭/开启的 prompt_tokens 可分",
|
||||||
|
"PASS" if off_max < on_min else "FAIL",
|
||||||
|
f"关闭档最大 {off_max} < 开启档最小 {on_min}",
|
||||||
|
off + on,
|
||||||
|
)
|
||||||
|
assert off_max < on_min, (
|
||||||
|
f"两档的 prompt_tokens 未分开(关闭最大 {off_max},开启最小 {on_min}): 注入可能没到达模型"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_l3_no_opinion_is_the_model_default(self):
|
||||||
|
obs = await _run_rounds(_ROUNDS, model="MiniMax-M3", enable_thinking=None)
|
||||||
|
# M3 的默认档实测就是不推理(findings §2.1),所以不干预时也应观测不到推理。
|
||||||
|
# 注意这**不能**反过来证明关闭方向生效 —— L1 与本行同分布,区分二者的是
|
||||||
|
# L2b 的 prompt_tokens 与 L3b 的乱码值反证
|
||||||
|
quiet = [o for o in obs if _reasoning_off(o)]
|
||||||
|
_record(
|
||||||
|
"L3",
|
||||||
|
"enable_thinking=None(不干预,基线)",
|
||||||
|
"PASS" if len(quiet) == len(obs) else "FAIL",
|
||||||
|
f"{len(quiet)}/{len(obs)} 轮未推理(M3 默认档本就不推理)",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(quiet) == len(obs), f"M3 默认档不应推理: {obs}"
|
||||||
|
|
||||||
|
async def test_l3b_none_is_recognised_not_silently_dropped(self):
|
||||||
|
"""反证: 关闭方向的观测必须排除"参数被静默丢弃"这一伪解释。
|
||||||
|
|
||||||
|
L1(关闭)与 L3(不干预)在 M3 上**同分布**——因为 M3 默认档本就不推理。
|
||||||
|
所以 L1 单独看不能区分"`none` 真的被消费"与"`none` 被中转吞了",而后者
|
||||||
|
正是 issue #5 的原始故障形态(`enable_thinking` 就是这么被吞的)。
|
||||||
|
|
||||||
|
判别方法: 发一个**非法值**。若未知值会被静默丢弃,它的表现应与"不注入"
|
||||||
|
一致(不推理);实测它反而开启了推理,说明网关认这个键、只是不认这个值。
|
||||||
|
既然非法值与 `none` 的表现不同,`none` 就必然是被识别的枚举值。
|
||||||
|
"""
|
||||||
|
rounds = max(3, _ROUNDS // 3)
|
||||||
|
bogus = await _run_rounds(
|
||||||
|
rounds,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
enable_thinking=None,
|
||||||
|
extra_body={"reasoning_effort": "definitely-not-a-real-level"},
|
||||||
|
)
|
||||||
|
off = await _run_rounds(rounds, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
bogus_on = [o for o in bogus if _reasoning_on(o)]
|
||||||
|
off_quiet = [o for o in off if _reasoning_off(o)]
|
||||||
|
ok = len(bogus_on) * 2 > len(bogus) and len(off_quiet) == len(off)
|
||||||
|
_record(
|
||||||
|
"L3b",
|
||||||
|
"非法值反证 none 被识别",
|
||||||
|
"PASS" if ok else "FAIL",
|
||||||
|
f"非法值 {len(bogus_on)}/{len(bogus)} 轮推理,none {len(off_quiet)}/{len(off)} 轮不推理"
|
||||||
|
"(两者表现不同 ⇒ none 非被丢弃)",
|
||||||
|
bogus + off,
|
||||||
|
)
|
||||||
|
assert len(bogus_on) * 2 > len(bogus), (
|
||||||
|
f"非法值未开启推理,无法排除'未知值被静默丢弃'这一伪解释: {bogus}"
|
||||||
|
)
|
||||||
|
assert len(off_quiet) == len(off), f"none 未关闭推理: {off}"
|
||||||
|
|
||||||
|
async def test_l4_extra_body_overrides_the_profile(self):
|
||||||
|
"""profile 注入 none,extra_body 要求 high —— 后者必须赢(优先级不可调换)。
|
||||||
|
|
||||||
|
判据是行为而非报文: 若 extra_body 没赢,拿到的就是 none 的结果(不推理)。
|
||||||
|
"""
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
obs = await _run_rounds(
|
||||||
|
rounds,
|
||||||
|
model="MiniMax-M3",
|
||||||
|
enable_thinking=False,
|
||||||
|
extra_body={"reasoning_effort": "high"},
|
||||||
|
)
|
||||||
|
ons = [o for o in obs if _reasoning_on(o)]
|
||||||
|
_record(
|
||||||
|
"L4",
|
||||||
|
"extra_body 覆盖 profile 注入",
|
||||||
|
"PASS" if len(ons) * 2 > len(obs) else "FAIL",
|
||||||
|
f"{len(ons)}/{len(obs)} 轮观察到推理(证明 high 生效而非 none)",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(ons) * 2 > len(obs), f"extra_body 未能覆盖 profile: {obs}"
|
||||||
|
|
||||||
|
async def test_l5_non_stream_path_matches_stream(self):
|
||||||
|
"""非流式快路径独立于流式实现,采集与注入都要各自验一遍。"""
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
off = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=False)
|
||||||
|
on = await _run_rounds(rounds, stream=False, model="MiniMax-M3", enable_thinking=True)
|
||||||
|
offs = [o for o in off if _reasoning_off(o)]
|
||||||
|
ons = [o for o in on if _reasoning_on(o)]
|
||||||
|
ok = len(offs) == len(off) and len(ons) * 2 > len(on)
|
||||||
|
_record(
|
||||||
|
"L5",
|
||||||
|
"非流式路径重跑 L1/L2",
|
||||||
|
"PASS" if ok else "FAIL",
|
||||||
|
f"关闭 {len(offs)}/{len(off)} 轮,开启 {len(ons)}/{len(on)} 轮",
|
||||||
|
off + on,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(off), f"非流式关闭方向未满足: {off}"
|
||||||
|
assert len(ons) * 2 > len(on), f"非流式开启方向未满足: {on}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherProviders:
|
||||||
|
"""qwen / deepseek 的 profile 是既有实现,本组防的是"改 minimax 时误伤它们"。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("matrix", "provider", "model"),
|
||||||
|
[("L6", "qwen", "qwen3.7-plus"), ("L7", "deepseek", "deepseek-v4-pro")],
|
||||||
|
)
|
||||||
|
async def test_existing_profiles_still_disable(self, matrix, provider, model):
|
||||||
|
desc = f"{provider} enable_thinking=False"
|
||||||
|
try:
|
||||||
|
obs = await _run_rounds(_ROUNDS, provider=provider, model=model, enable_thinking=False)
|
||||||
|
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
||||||
|
# 只吞网关/网络类失败。**不吞 ValueError / RequestRejected** ——
|
||||||
|
# 那两类正是本次改动最可能的误伤方向,吞掉就成了纪律(c)要防的静默
|
||||||
|
_skip_if_unreachable(exc, matrix, desc)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
_record(
|
||||||
|
matrix,
|
||||||
|
desc,
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL",
|
||||||
|
f"{len(offs)}/{len(obs)} 轮确认未推理",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), f"{provider} 关闭方向未满足: {obs}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityDrift:
|
||||||
|
"""L8 漂移哨兵: 能力表过期是必然事件,这里是它的过期告警。"""
|
||||||
|
|
||||||
|
def test_every_capability_has_a_provider_mapping(self):
|
||||||
|
"""能力表新增条目必须同步本测试的映射,否则该行会被静默跳过。"""
|
||||||
|
missing = sorted(set(DEFAULT_CAPABILITIES) - set(_MODEL_PROVIDER))
|
||||||
|
assert not missing, f"这些模型缺 provider 映射,L8 会漏测: {missing}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", sorted(DEFAULT_CAPABILITIES))
|
||||||
|
async def test_declared_capability_matches_reality(self, model):
|
||||||
|
cap = get_capability(model)
|
||||||
|
provider = _MODEL_PROVIDER[model]
|
||||||
|
rounds = max(3, _ROUNDS // 2)
|
||||||
|
desc = f"{model} 声明 can_disable={cap.can_disable}"
|
||||||
|
if not cap.can_disable:
|
||||||
|
# 声明关不掉: 装配期就该炸,炸了即与声明一致(不必真调用)
|
||||||
|
with pytest.raises(ValueError, match=model):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
_settings(provider=provider, model=model, enable_thinking=False)
|
||||||
|
)
|
||||||
|
_record("L8", desc, "PASS", "装配期按声明拒绝,与实测一致")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
obs = await _run_rounds(rounds, provider=provider, model=model, enable_thinking=False)
|
||||||
|
except (AllSourcesExhausted, SourceDeadError, TransientError) as exc:
|
||||||
|
_skip_if_unreachable(exc, "L8", desc)
|
||||||
|
offs = [o for o in obs if _reasoning_off(o)]
|
||||||
|
verdict = Counter(_reasoning_off(o) for o in obs)
|
||||||
|
_record(
|
||||||
|
"L8",
|
||||||
|
desc,
|
||||||
|
"PASS" if len(offs) == len(obs) else "FAIL(能力表已漂移)",
|
||||||
|
f"实测 {dict(verdict)};声明 can_disable=True 要求每轮关闭",
|
||||||
|
obs,
|
||||||
|
)
|
||||||
|
assert len(offs) == len(obs), (
|
||||||
|
f"能力表漂移: {model} 声明可关闭推理,实测未关掉 —— 请复测后更新 DEFAULT_CAPABILITIES"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssemblyGuardAgainstRealConfig:
|
||||||
|
"""L9: 纯本地,但用的是 .env 里的真实配置形态,防"守卫只在合成配置上生效"。"""
|
||||||
|
|
||||||
|
def test_l9_m27_rejected_at_assembly(self):
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
_settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||||
|
)
|
||||||
|
_record("L9", "M2.7 + enable_thinking=False", "PASS", "装配期报错,未发出任何请求")
|
||||||
|
|
||||||
|
def test_l9_unknown_shape_rejected_at_assembly(self):
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
GatewayClient.from_settings(
|
||||||
|
_settings(provider="openai", model="kimi-k3", enable_thinking=False)
|
||||||
|
)
|
||||||
|
_record("L9", "provider=openai 形态未知", "PASS", "装配期报错并指路")
|
||||||
|
|
||||||
|
async def test_transport_layer_rejects_when_guard_is_bypassed(self):
|
||||||
|
"""构造函数全量注入这条路绕过装配守卫,transport 必须兜住并归四分类。"""
|
||||||
|
settings = _settings(provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||||
|
client = GatewayClient.from_settings(
|
||||||
|
dataclasses.replace(
|
||||||
|
settings, sources=(dataclasses.replace(settings.sources[0], enable_thinking=None),)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# 装配用 None 绕过守卫,再把源换成 False 直接喂给 transport
|
||||||
|
bad = dataclasses.replace(settings.sources[0], enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
||||||
|
await client._terminal._transport.complete(
|
||||||
|
messages=[{"role": "user", "content": _PROMPT}],
|
||||||
|
source=bad,
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="e2e-guard",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
_record("L9", "绕过装配守卫时 transport 兜底", "PASS", "RequestRejectedError,属四分类")
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -204,3 +205,74 @@ class TestTransientErrorExport:
|
|||||||
|
|
||||||
assert isinstance(ei.value, polygateway.AllSourcesExhausted)
|
assert isinstance(ei.value, polygateway.AllSourcesExhausted)
|
||||||
assert isinstance(ei.value.__cause__, TransientError)
|
assert isinstance(ei.value.__cause__, TransientError)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplingThroughStack:
|
||||||
|
"""issue #4: 采样参数经完整洋葱到达请求体,且缓存/遥测口径一致。"""
|
||||||
|
|
||||||
|
async def test_reaches_wire_and_lands_in_telemetry(self, tmp_path):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(json.loads(request.content))
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
recorder = SQLiteRecorder(db)
|
||||||
|
client = _full_client(handler, telemetry=recorder)
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
assert seen[0]["seed"] == 42 # 穿过全栈到达线上
|
||||||
|
rows = sqlite3.connect(db).execute("SELECT sampling FROM llm_calls").fetchall()
|
||||||
|
assert json.loads(rows[0][0]) == {"seed": 42}
|
||||||
|
|
||||||
|
async def test_config_level_merges_and_records(self, tmp_path):
|
||||||
|
"""源级 extra_body 只有 emit_attempt 记得到(唯一有生效源的入口)。"""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(json.loads(request.content))
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
recorder = SQLiteRecorder(db)
|
||||||
|
client = GatewayClient(
|
||||||
|
scope="llm",
|
||||||
|
sources=[src],
|
||||||
|
selector=RoundRobinSelector(),
|
||||||
|
limiter=InMemoryLimiter(
|
||||||
|
scope="llm", sources={src.name: src}, global_limits=GlobalLimits(0, 0, 0)
|
||||||
|
),
|
||||||
|
breaker=InMemoryGate(config=_BREAKER),
|
||||||
|
transport=OpenAICompatTransport(
|
||||||
|
client_factory=lambda s: httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
),
|
||||||
|
retry=RetryPolicy(2, 2.0, 30.0),
|
||||||
|
backpressure=BackpressurePolicy(300.0, 0.01),
|
||||||
|
telemetry=recorder,
|
||||||
|
structured_strategy=JsonRepairStrategy(),
|
||||||
|
sleep=_noop_sleep,
|
||||||
|
)
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1})
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
assert seen[0]["temperature"] == 0 and seen[0]["seed"] == 1
|
||||||
|
rows = sqlite3.connect(db).execute("SELECT sampling FROM llm_calls").fetchall()
|
||||||
|
assert json.loads(rows[0][0]) == {"seed": 1, "temperature": 0}
|
||||||
|
|
||||||
|
async def test_differing_seed_bypasses_cache_end_to_end(self):
|
||||||
|
"""issue 场景全栈回归: 逐 rollout 变 seed 必须真的回源。"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls.append(json.loads(request.content)["seed"])
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
client = _full_client(handler, cache=InMemoryCache())
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1})
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 2})
|
||||||
|
second_same = await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 1})
|
||||||
|
assert calls == [1, 2] # 两个不同 seed 各自回源
|
||||||
|
assert second_same.cache_hit is True # 同 seed 才命中
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||||
@@ -100,6 +105,10 @@ async def _record_minimal(
|
|||||||
"cache_hit": False,
|
"cache_hit": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
"cost": None,
|
"cost": None,
|
||||||
|
"cached_prompt_tokens": None,
|
||||||
|
"model_reported": None,
|
||||||
|
"sampling": None,
|
||||||
|
"reasoning_tokens": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -115,6 +124,111 @@ async def _fetch(dsn: str, sql: str, *args):
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_DDL = """
|
||||||
|
CREATE TABLE {schema}.llm_calls (
|
||||||
|
call_id TEXT PRIMARY KEY,
|
||||||
|
parent_call_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
messages TEXT NOT NULL,
|
||||||
|
response TEXT NOT NULL,
|
||||||
|
thinking TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INTEGER NOT NULL,
|
||||||
|
completion_tokens INTEGER NOT NULL,
|
||||||
|
usage_source TEXT NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
ttft_ms DOUBLE PRECISION,
|
||||||
|
max_inter_token_ms DOUBLE PRECISION,
|
||||||
|
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
error TEXT,
|
||||||
|
cost DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def legacy_schema(dsn):
|
||||||
|
"""在**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。
|
||||||
|
|
||||||
|
绝不碰共享的 public.llm_calls: 用 search_path 把 recorder 指向临时 schema,
|
||||||
|
teardown 只 DROP 自己建的 schema。
|
||||||
|
"""
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
name = f"pgwtest_{uuid4().hex[:8]}"
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute(f"CREATE SCHEMA {name}")
|
||||||
|
await conn.execute(_LEGACY_DDL.format(schema=name))
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
sep = "&" if "?" in dsn else "?"
|
||||||
|
yield f"{dsn}{sep}options=-csearch_path%3D{name}", name
|
||||||
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
|
try:
|
||||||
|
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityColumns:
|
||||||
|
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||||
|
|
||||||
|
async def test_values_round_trip(self, dsn):
|
||||||
|
recorder = PostgresRecorder(dsn)
|
||||||
|
try:
|
||||||
|
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||||
|
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01")
|
||||||
|
await _record_minimal(
|
||||||
|
recorder, call_id=_cid("samp"), sampling='{"seed": 42, "temperature": 0}'
|
||||||
|
)
|
||||||
|
rows = await _fetch(
|
||||||
|
dsn,
|
||||||
|
"SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls "
|
||||||
|
"WHERE call_id LIKE $1",
|
||||||
|
f"{_RUN_PREFIX}-%",
|
||||||
|
)
|
||||||
|
by_id = {r["call_id"]: r for r in rows}
|
||||||
|
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64
|
||||||
|
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
|
||||||
|
assert by_id[_cid("model")]["cached_prompt_tokens"] is None
|
||||||
|
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01"
|
||||||
|
# issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在)
|
||||||
|
assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0}
|
||||||
|
assert by_id[_cid("hit")]["sampling"] is None
|
||||||
|
finally:
|
||||||
|
await recorder.aclose()
|
||||||
|
|
||||||
|
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
||||||
|
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
||||||
|
schema_dsn, schema = legacy_schema
|
||||||
|
recorder = PostgresRecorder(schema_dsn)
|
||||||
|
try:
|
||||||
|
await _record_minimal(
|
||||||
|
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||||
|
)
|
||||||
|
cols = await _fetch(
|
||||||
|
schema_dsn,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||||
|
schema,
|
||||||
|
)
|
||||||
|
# ALTER 只能追加到末尾: 与新建库的列序一致才不会分叉
|
||||||
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
|
rows = await _fetch(
|
||||||
|
schema_dsn,
|
||||||
|
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1",
|
||||||
|
_cid("legacy"),
|
||||||
|
)
|
||||||
|
assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
|
||||||
|
finally:
|
||||||
|
await recorder.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestSchema:
|
class TestSchema:
|
||||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn)
|
||||||
|
|||||||
@@ -53,6 +53,35 @@ class TestKeyFormula:
|
|||||||
def test_any_dimension_change_changes_key(self, a, b):
|
def test_any_dimension_change_changes_key(self, a, b):
|
||||||
assert build_cache_key(*a) != build_cache_key(*b)
|
assert build_cache_key(*a) != build_cache_key(*b)
|
||||||
|
|
||||||
|
def test_empty_sampling_keeps_legacy_key(self):
|
||||||
|
"""空采样参数时键形逐字不变,存量缓存不被全量作废(issue #4 决策 C)。
|
||||||
|
|
||||||
|
golden 值取自加 sampling 维度之前的实现,不得随实现漂移。
|
||||||
|
"""
|
||||||
|
assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", None) == (
|
||||||
|
"pgw:cache:c54544e8672f4c91373b4a72716a88497445b440b89445aa5379b356b228f58b"
|
||||||
|
)
|
||||||
|
assert build_cache_key("qwen-max", [{"role": "user", "content": "hi"}], "proj", "s1") == (
|
||||||
|
"pgw:cache:eed9cd9cc06acc0dedf4f337b74e06ed3482afdc30fa2acedd194f6cc1df33bf"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_differing_seed_changes_key(self):
|
||||||
|
"""issue #4 的直接回归: 5 个 seed 若共用一个 key,标准差会恒为 0。"""
|
||||||
|
k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1})
|
||||||
|
k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 2})
|
||||||
|
assert k1 != k2
|
||||||
|
|
||||||
|
def test_sampling_key_order_irrelevant(self):
|
||||||
|
k1 = build_cache_key("m", _MSGS, "proj", None, sampling={"seed": 1, "temperature": 0})
|
||||||
|
k2 = build_cache_key("m", _MSGS, "proj", None, sampling={"temperature": 0, "seed": 1})
|
||||||
|
assert k1 == k2
|
||||||
|
|
||||||
|
def test_empty_sampling_equals_omitted(self):
|
||||||
|
"""空 dict 与不传须同键,否则升级后存量缓存全部 miss。"""
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None, sampling={}) == build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None
|
||||||
|
)
|
||||||
|
|
||||||
def test_multimodal_part_digested_not_inlined(self):
|
def test_multimodal_part_digested_not_inlined(self):
|
||||||
big_b64 = "data:image/png;base64," + "A" * 1_000_000
|
big_b64 = "data:image/png;base64," + "A" * 1_000_000
|
||||||
messages = [
|
messages = [
|
||||||
@@ -120,6 +149,32 @@ class TestCacheFlow:
|
|||||||
assert second.call_id != first.call_id # 命中生成独立 cache_call_id
|
assert second.call_id != first.call_id # 命中生成独立 cache_call_id
|
||||||
assert terminal.calls == 1 # 未再触达内层
|
assert terminal.calls == 1 # 未再触达内层
|
||||||
|
|
||||||
|
async def test_differing_sampling_does_not_hit(self):
|
||||||
|
"""issue #4 的中间件层回归: 逐 rollout 变 seed 必须回源,不得复用响应。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
||||||
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 2}), terminal)
|
||||||
|
assert terminal.calls == 2 # 两次都回源
|
||||||
|
# 同 seed 才命中
|
||||||
|
third = await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
||||||
|
assert third.cache_hit is True and terminal.calls == 2
|
||||||
|
|
||||||
|
async def test_structured_injection_does_not_pollute_key(self):
|
||||||
|
"""CacheMW 读 sampling 而非 overlay: 结构化注入不该改变缓存身份。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
await mw(ChatRequest(messages=_MSGS, sampling={"seed": 1}), terminal)
|
||||||
|
polluted = ChatRequest(
|
||||||
|
messages=_MSGS,
|
||||||
|
sampling={"seed": 1},
|
||||||
|
overlay={"seed": 1, "response_format": {"type": "json_object"}},
|
||||||
|
)
|
||||||
|
assert (await mw(polluted, terminal)).cache_hit is True
|
||||||
|
assert terminal.calls == 1
|
||||||
|
|
||||||
async def test_per_call_namespace_overrides_default(self):
|
async def test_per_call_namespace_overrides_default(self):
|
||||||
backend = InMemoryCache()
|
backend = InMemoryCache()
|
||||||
mw = _mw(backend)
|
mw = _mw(backend)
|
||||||
@@ -150,6 +205,49 @@ class TestCacheFlow:
|
|||||||
assert terminal.calls == 2
|
assert terminal.calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityFieldsOnHit:
|
||||||
|
"""issue #3 决策 B1: 命中行原样回放,与 model/prompt_tokens 同一口径。"""
|
||||||
|
|
||||||
|
async def test_fields_replayed_on_hit(self):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(
|
||||||
|
_resp(cached_prompt_tokens=64, model_reported="MiniMax-Text-01-250321")
|
||||||
|
)
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True
|
||||||
|
assert hit.cached_prompt_tokens == 64
|
||||||
|
assert hit.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_legacy_cache_entry_without_new_keys_rehydrates(self):
|
||||||
|
"""旧格式条目(无这两个键)必须照常重建为 None,不得抛异常回源。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None)
|
||||||
|
legacy = {
|
||||||
|
"content": "legacy",
|
||||||
|
"thinking": "",
|
||||||
|
"model": "m",
|
||||||
|
"provider": "p",
|
||||||
|
"prompt_tokens": 1,
|
||||||
|
"completion_tokens": 2,
|
||||||
|
"latency_ms": 30,
|
||||||
|
"ttft_ms": 5.0,
|
||||||
|
"max_inter_token_ms": 2.0,
|
||||||
|
"cache_hit": False,
|
||||||
|
"call_id": "orig",
|
||||||
|
"source_name": "s1",
|
||||||
|
"cost": None,
|
||||||
|
"usage_source": "measured",
|
||||||
|
}
|
||||||
|
await backend.set(key, json.dumps(legacy), ttl_s=100)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.content == "legacy" and terminal.calls == 0 # 真的走了缓存
|
||||||
|
assert hit.cached_prompt_tokens is None and hit.model_reported is None
|
||||||
|
|
||||||
|
|
||||||
class _BrokenBackend:
|
class _BrokenBackend:
|
||||||
async def get(self, key):
|
async def get(self, key):
|
||||||
raise ConnectionError("redis down")
|
raise ConnectionError("redis down")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from polygateway.backends.memory.cache import InMemoryCache
|
|||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
from polygateway.sources import RoundRobinSelector
|
from polygateway.sources import RoundRobinSelector
|
||||||
from polygateway.structured.json_repair import JsonRepairStrategy
|
from polygateway.structured.json_repair import JsonRepairStrategy
|
||||||
|
from polygateway.structured.native_schema import NativeSchemaStrategy
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
@@ -117,6 +118,133 @@ class TestChatEndToEnd:
|
|||||||
await client.chat([{"role": "user", "content": "hi"}], structured="json")
|
await client.chat([{"role": "user", "content": "hi"}], structured="json")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplingOverlay:
|
||||||
|
"""调用级采样参数入口(issue #4 Task 3)。"""
|
||||||
|
|
||||||
|
def _capturing_client(self, captured, **overrides):
|
||||||
|
def handler(request):
|
||||||
|
captured.append(json.loads(request.content))
|
||||||
|
return _sse()
|
||||||
|
|
||||||
|
return _client(handler=handler, **overrides)
|
||||||
|
|
||||||
|
async def test_overlay_reaches_request_body(self):
|
||||||
|
captured = []
|
||||||
|
async with self._capturing_client(captured) as client:
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
||||||
|
assert captured[0]["seed"] == 42
|
||||||
|
|
||||||
|
async def test_call_level_beats_config_level(self):
|
||||||
|
"""优先级: 调用级 > 配置级(设计决策 A)。"""
|
||||||
|
captured = []
|
||||||
|
source = _source(extra_body={"temperature": 0, "top_p": 0.9})
|
||||||
|
async with self._capturing_client(captured, sources=[source]) as client:
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"temperature": 1})
|
||||||
|
assert captured[0]["temperature"] == 1 # 调用级覆盖
|
||||||
|
assert captured[0]["top_p"] == 0.9 # 配置级未被顶掉的键保留
|
||||||
|
|
||||||
|
async def test_structured_injection_beats_call_level(self):
|
||||||
|
"""结构化注入优先级最高: 它关系到响应能否被解析(设计决策 A)。"""
|
||||||
|
captured = []
|
||||||
|
client = self._capturing_client(captured, structured_strategy=NativeSchemaStrategy())
|
||||||
|
async with client:
|
||||||
|
await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
structured="json",
|
||||||
|
overlay={"response_format": {"type": "text"}},
|
||||||
|
)
|
||||||
|
assert captured[0]["response_format"] != {"type": "text"}
|
||||||
|
|
||||||
|
async def test_protected_key_rejected_before_onion(self):
|
||||||
|
"""保护键在进洋葱之前就报错,transport 一次都不该被碰到。"""
|
||||||
|
captured = []
|
||||||
|
async with self._capturing_client(captured) as client:
|
||||||
|
with pytest.raises(ValueError, match="stream"):
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"stream": False})
|
||||||
|
assert captured == []
|
||||||
|
|
||||||
|
async def test_unserializable_value_rejected_before_onion(self):
|
||||||
|
"""裸 TypeError 会在 CacheMW 的降级 try 之外炸且无遥测(设计决策 B)。"""
|
||||||
|
captured = []
|
||||||
|
async with self._capturing_client(captured) as client:
|
||||||
|
with pytest.raises(ValueError, match="JSON"):
|
||||||
|
await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}], overlay={"temperature": object()}
|
||||||
|
)
|
||||||
|
assert captured == []
|
||||||
|
|
||||||
|
async def test_caller_dict_mutation_does_not_leak(self):
|
||||||
|
"""调用方逐次改 seed 复用同一 dict 是预期模式(设计决策 E)。"""
|
||||||
|
captured = []
|
||||||
|
caller_overlay = {"seed": 1}
|
||||||
|
async with self._capturing_client(captured) as client:
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay=caller_overlay)
|
||||||
|
caller_overlay["seed"] = 2
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], overlay=caller_overlay)
|
||||||
|
assert [c["seed"] for c in captured] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelFingerprint:
|
||||||
|
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
|
||||||
|
|
||||||
|
def test_empty_extra_body_keeps_legacy_fingerprint(self):
|
||||||
|
"""全源无 extra_body 时字面量与旧实现逐字相同,不触发存量缓存冷启动。"""
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
sources = [_source(), _source(name="qwen_2", model="qwen-plus")]
|
||||||
|
assert build_model_fingerprint(sources) == "qwen-max,qwen-plus"
|
||||||
|
|
||||||
|
def test_extra_body_changes_fingerprint(self):
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
plain = build_model_fingerprint([_source()])
|
||||||
|
tuned = build_model_fingerprint([_source(extra_body={"temperature": 0})])
|
||||||
|
assert plain != tuned
|
||||||
|
assert tuned.startswith("qwen-max|") # 旧字面量仍是前缀,便于人眼辨认
|
||||||
|
|
||||||
|
def test_enable_thinking_changes_fingerprint(self):
|
||||||
|
"""issue #5 配套: thinking 一旦真正改变请求体,就必须进缓存身份。
|
||||||
|
|
||||||
|
否则"关掉推理后重启"会读到开着推理时缓存的旧响应——issue #4 为
|
||||||
|
temperature 写过逐字相同的理由。
|
||||||
|
"""
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
plain = build_model_fingerprint([_source()])
|
||||||
|
off = build_model_fingerprint([_source(enable_thinking=False)])
|
||||||
|
on = build_model_fingerprint([_source(enable_thinking=True)])
|
||||||
|
assert len({plain, off, on}) == 3
|
||||||
|
|
||||||
|
def test_extra_body_only_fingerprint_is_byte_identical_to_before(self):
|
||||||
|
"""只配 extra_body、不表态 thinking 的存量源不得触发冷启动。
|
||||||
|
|
||||||
|
字面量在此硬编码: 这条断言的价值全在"逐字相同",改实现时必须先看见它红。
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
mark = json.dumps(["qwen-max", {"temperature": 0}], sort_keys=True, ensure_ascii=False)
|
||||||
|
expected = "qwen-max|" + hashlib.sha256(mark.encode("utf-8")).hexdigest()
|
||||||
|
assert build_model_fingerprint([_source(extra_body={"temperature": 0})]) == expected
|
||||||
|
|
||||||
|
def test_source_rename_does_not_change_fingerprint(self):
|
||||||
|
"""指纹按 (model, extra_body) 而非源名: 改名不该误触全量冷启动。"""
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
a = build_model_fingerprint([_source(name="qwen_1", extra_body={"temperature": 0})])
|
||||||
|
b = build_model_fingerprint([_source(name="renamed", extra_body={"temperature": 0})])
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
def test_differing_extra_body_across_sources_is_distinguished(self):
|
||||||
|
from polygateway.client import build_model_fingerprint
|
||||||
|
|
||||||
|
a = build_model_fingerprint([_source(extra_body={"temperature": 0})])
|
||||||
|
b = build_model_fingerprint([_source(extra_body={"temperature": 1})])
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
class TestFactories:
|
class TestFactories:
|
||||||
def test_from_env_assembles(self):
|
def test_from_env_assembles(self):
|
||||||
client = GatewayClient.from_env("LLM", env=_ENV)
|
client = GatewayClient.from_env("LLM", env=_ENV)
|
||||||
|
|||||||
@@ -103,6 +103,35 @@ class TestSourceAggregation:
|
|||||||
GatewaySettings.from_env("LLM", env=env)
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtraBodyParsing:
|
||||||
|
"""配置级采样参数的 env 解析(issue #4 Task 2)。"""
|
||||||
|
|
||||||
|
def test_json_object_parsed(self):
|
||||||
|
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"temperature": 0, "seed": 42}'})
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.sources[0].extra_body == {"temperature": 0, "seed": 42}
|
||||||
|
|
||||||
|
def test_absent_defaults_to_empty(self):
|
||||||
|
assert GatewaySettings.from_env("LLM", env=_env()).sources[0].extra_body == {}
|
||||||
|
|
||||||
|
def test_invalid_json_fails_loudly(self):
|
||||||
|
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "{invalid"})
|
||||||
|
with pytest.raises(ValueError, match="EXTRA_BODY"):
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
|
||||||
|
def test_non_object_json_fails(self):
|
||||||
|
"""数组/标量都不是请求体片段,静默接受会让参数悄悄不生效。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": "[1, 2]"})
|
||||||
|
with pytest.raises(ValueError, match="JSON 对象"):
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
|
||||||
|
def test_protected_key_rejected_through_assembly(self):
|
||||||
|
"""校验确实挂在装配路径上(而非只在 types.py 里孤立存在)。"""
|
||||||
|
env = _env(**{"LLM__QWEN__1__EXTRA_BODY": '{"model": "sneaky"}'})
|
||||||
|
with pytest.raises(ValueError, match="model"):
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
|
||||||
|
|
||||||
class TestResilienceKeys:
|
class TestResilienceKeys:
|
||||||
def test_flat_legacy_keys(self):
|
def test_flat_legacy_keys(self):
|
||||||
s = GatewaySettings.from_env("LLM", env=_env())
|
s = GatewaySettings.from_env("LLM", env=_env())
|
||||||
@@ -431,6 +460,36 @@ class TestCrossFieldInvariants:
|
|||||||
with pytest.raises(ValueError, match="lease_ttl_s"):
|
with pytest.raises(ValueError, match="lease_ttl_s"):
|
||||||
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
GatewayClient.from_settings(dataclasses.replace(base, lease_ttl_s=1.0))
|
||||||
|
|
||||||
|
# —— 推理开关的装配守卫(issue #5)——
|
||||||
|
|
||||||
|
def _thinking_sources(self, provider, model, enable_thinking):
|
||||||
|
base = self._base()
|
||||||
|
src = dataclasses.replace(
|
||||||
|
base.sources[0], provider=provider, model=model, enable_thinking=enable_thinking
|
||||||
|
)
|
||||||
|
return dataclasses.replace(base, sources=(src,))
|
||||||
|
|
||||||
|
def test_model_that_cannot_disable_thinking_fails_at_assembly(self):
|
||||||
|
"""M2.x 关不掉推理: 配了 false 必须当场炸,而不是装出一个骗人的 client。"""
|
||||||
|
settings = self._thinking_sources("minimax", "MiniMax-M2.7", False)
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
GatewayClient.from_settings(settings)
|
||||||
|
|
||||||
|
def test_unknown_thinking_shape_fails_at_assembly(self):
|
||||||
|
"""provider=openai 是任意兼容厂商的兜底段名,形态未知即报错并指路。"""
|
||||||
|
settings = self._thinking_sources("openai", "kimi-k3", False)
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
GatewayClient.from_settings(settings)
|
||||||
|
|
||||||
|
def test_supported_combination_assembles(self):
|
||||||
|
settings = self._thinking_sources("minimax", "MiniMax-M3", False)
|
||||||
|
assert GatewayClient.from_settings(settings) is not None
|
||||||
|
|
||||||
|
def test_not_taking_a_position_never_trips_the_guard(self):
|
||||||
|
"""enable_thinking=None(不干预)对任何 provider 都不该被守卫拦下。"""
|
||||||
|
settings = self._thinking_sources("openai", "kimi-k3", None)
|
||||||
|
assert GatewayClient.from_settings(settings) is not None
|
||||||
|
|
||||||
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
def test_ocr_settings_cannot_wrap_invalid_gateway(self):
|
||||||
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
"""OcrSettings/EmbeddingSettings 只是包一层 GatewaySettings,自动继承同一把关。"""
|
||||||
base = self._base()
|
base = self._base()
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
VT adapters/embedding.py(归一化);库裁决见设计 §7.3 表。
|
VT adapters/embedding.py(归一化);库裁决见设计 §7.3 表。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
@@ -350,6 +352,46 @@ class TestEmbedTelemetry:
|
|||||||
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
|
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtraBodyStripped:
|
||||||
|
"""issue #4 决策 G: embedding 路径不消费 extra_body,剥离并 warning。"""
|
||||||
|
|
||||||
|
async def test_stripped_with_warning_but_assembly_succeeds(self):
|
||||||
|
"""报错会让下游整个装配起不来,而这条路径本无采样语义(人类拍板)。"""
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
client, _ = _embed_client([_src(extra_body={"temperature": 0})], ["ok"])
|
||||||
|
assert client._sources[0].extra_body == {}
|
||||||
|
assert any("extra_body" in m for m in warnings)
|
||||||
|
assert any("dimensions" in m for m in warnings) # 文案须指路,不能只说不支持
|
||||||
|
await client.embed(["hi"]) # 装配后可正常工作
|
||||||
|
|
||||||
|
async def test_telemetry_never_records_a_parameter_that_was_not_sent(self):
|
||||||
|
"""剥离的真正理由: embed payload 硬编码 {model, input},不剥离则审计表
|
||||||
|
|
||||||
|
会显示这次调用带了 temperature=0——那是数据造假,比参数失效更坏。
|
||||||
|
"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
client, _ = _embed_client([_src(extra_body={"temperature": 0})], ["ok"], telemetry=rec)
|
||||||
|
await client.embed(["hi"])
|
||||||
|
assert rec.rows[0]["sampling"] is None
|
||||||
|
|
||||||
|
async def test_no_warning_without_extra_body(self):
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
client, _ = _embed_client([_src()], ["ok"])
|
||||||
|
assert client._sources[0].extra_body == {}
|
||||||
|
assert not [m for m in warnings if "extra_body" in m]
|
||||||
|
|
||||||
|
|
||||||
class TestEmbeddingSettings:
|
class TestEmbeddingSettings:
|
||||||
_ENV = {
|
_ENV = {
|
||||||
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
|
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.backends.memory.breaker import InMemoryGate
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
@@ -377,6 +378,27 @@ class TestCheckHealth:
|
|||||||
await task
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtraBodyStripped:
|
||||||
|
"""issue #4 决策 G: OCR 路径只发 multipart 表单,剥离 extra_body 并 warning。"""
|
||||||
|
|
||||||
|
def test_stripped_with_warning_but_assembly_succeeds(self):
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
client, _, _ = _client([_src(extra_body={"temperature": 0})], ["text"])
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert client._sources[0].extra_body == {}
|
||||||
|
assert any("extra_body" in m for m in messages)
|
||||||
|
|
||||||
|
async def test_telemetry_never_records_a_parameter_that_was_not_sent(self):
|
||||||
|
"""不剥离则审计表会显示这次 OCR 带了 temperature=0——数据造假。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client([_src(extra_body={"temperature": 0})], ["text"], telemetry=recorder)
|
||||||
|
await client.recognize_text(b"IMG")
|
||||||
|
assert recorder.rows[0]["sampling"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetry:
|
class TestTelemetry:
|
||||||
async def test_success_and_failure_recorded_without_image_bytes(self):
|
async def test_success_and_failure_recorded_without_image_bytes(self):
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
@@ -36,7 +37,7 @@ def _source(**overrides):
|
|||||||
return SourceConfig(**base)
|
return SourceConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
def _chunk(content=None, reasoning=None, usage=None):
|
def _chunk(content=None, reasoning=None, usage=None, model=None):
|
||||||
delta = {}
|
delta = {}
|
||||||
if content is not None:
|
if content is not None:
|
||||||
delta["content"] = content
|
delta["content"] = content
|
||||||
@@ -45,6 +46,8 @@ def _chunk(content=None, reasoning=None, usage=None):
|
|||||||
body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []}
|
body = {"choices": [{"delta": delta}]} if (delta or usage is None) else {"choices": []}
|
||||||
if usage is not None:
|
if usage is not None:
|
||||||
body["usage"] = usage
|
body["usage"] = usage
|
||||||
|
if model is not None:
|
||||||
|
body["model"] = model
|
||||||
return f"data: {json.dumps(body)}\n\n"
|
return f"data: {json.dumps(body)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -261,6 +264,212 @@ class TestEmptyCompletion:
|
|||||||
await _complete(_transport_for(handler), _source())
|
await _complete(_transport_for(handler), _source())
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityFields:
|
||||||
|
"""issue #3: 供应商 prompt cache 命中数与 API 实际返回的模型版本串。
|
||||||
|
|
||||||
|
网关报文一律不可信: 形态异常只归 None,绝不因一个可观测字段打断调用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _cached_usage(self, cached):
|
||||||
|
return {**_USAGE, "prompt_tokens_details": {"cached_tokens": cached}}
|
||||||
|
|
||||||
|
async def test_stream_reads_cached_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(128)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens == 128
|
||||||
|
|
||||||
|
async def test_non_stream_reads_cached_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._cached_usage(128),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.cached_prompt_tokens == 128
|
||||||
|
|
||||||
|
async def test_zero_cached_tokens_is_a_real_zero(self):
|
||||||
|
"""0(真实零命中)与 None(该源未上报)必须可区分——issue #3 的核心诉求。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens == 0
|
||||||
|
|
||||||
|
async def test_usage_without_details_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_missing_usage_frame_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
|
||||||
|
async def test_malformed_cached_tokens_degrade_to_none(self, bad):
|
||||||
|
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(bad)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_details_not_a_dict_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
usage = {**_USAGE, "prompt_tokens_details": "oops"}
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.cached_prompt_tokens is None
|
||||||
|
|
||||||
|
async def test_non_stream_reads_reported_model(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": _USAGE,
|
||||||
|
"model": "MiniMax-Text-01-250321",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_stream_keeps_the_first_reported_model(self):
|
||||||
|
"""末帧异常值不得覆盖首帧: 首次写入即固定。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(content="a", model="MiniMax-Text-01-250321"),
|
||||||
|
_chunk(content="b", model="something-else"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
async def test_empty_first_model_does_not_block_a_later_real_one(self):
|
||||||
|
"""首帧报空串不得锁死 sink: 守卫按"有效值"判断,否则真实版本会丢。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(content="a", model=""),
|
||||||
|
_chunk(content="b", model="MiniMax-Text-01-250321"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, "", " ", 123, {}])
|
||||||
|
async def test_missing_or_malformed_model_is_none(self, bad):
|
||||||
|
def handler(request):
|
||||||
|
body = {"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
if bad is not None:
|
||||||
|
body["model"] = bad
|
||||||
|
return httpx.Response(200, json=body)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.model_reported is None
|
||||||
|
|
||||||
|
async def test_raw_payload_is_unchanged(self):
|
||||||
|
"""新字段是独立格子,不改动 raw 的既有内容。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._cached_usage(5)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert set(result.raw) == {"usage"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReasoningTokens:
|
||||||
|
"""issue #6: 推理消耗的输出 token,与 issue #3 的 cached_tokens 对称。
|
||||||
|
|
||||||
|
实测三家供应商在"未推理"时是整个 completion_tokens_details 缺失,无人上报
|
||||||
|
0;且中转在上游不返回 usage 时会本地补算并吃掉该对象。故 None 的语义是
|
||||||
|
"本次调用未上报",不是"该源不上报"(findings §4c)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _reasoning_usage(self, reasoning):
|
||||||
|
return {**_USAGE, "completion_tokens_details": {"reasoning_tokens": reasoning}}
|
||||||
|
|
||||||
|
async def test_stream_reads_reasoning_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(7)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens == 7
|
||||||
|
|
||||||
|
async def test_non_stream_reads_reasoning_tokens(self):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "42"}}],
|
||||||
|
"usage": self._reasoning_usage(7),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), stream=False)
|
||||||
|
assert result.reasoning_tokens == 7
|
||||||
|
|
||||||
|
async def test_zero_reasoning_tokens_is_a_real_zero(self):
|
||||||
|
"""0(上报了且确实没推理)与 None(本次未上报)必须可区分。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(0)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens == 0
|
||||||
|
|
||||||
|
async def test_usage_without_details_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["abc", -1, True, 1.5, None, [], {"x": 1}])
|
||||||
|
async def test_malformed_reasoning_tokens_degrade_to_none(self, bad):
|
||||||
|
"""`True` 必须排除: Python 里 isinstance(True, int) 为真。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=self._reasoning_usage(bad)))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_details_not_a_dict_is_none(self):
|
||||||
|
def handler(request):
|
||||||
|
usage = {**_USAGE, "completion_tokens_details": "oops"}
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=usage))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source())
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
async def test_salvage_path_records_none_not_zero(self):
|
||||||
|
"""打捞路径拿不到 usage 帧: 记 None(未知)而非 0(确定没推理)。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), done=False)
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(missing_done="salvage"))
|
||||||
|
assert result.reasoning_tokens is None
|
||||||
|
|
||||||
|
|
||||||
class TestNonStreamFastPath:
|
class TestNonStreamFastPath:
|
||||||
async def test_non_stream_parses_message(self):
|
async def test_non_stream_parses_message(self):
|
||||||
def handler(request):
|
def handler(request):
|
||||||
@@ -298,6 +507,104 @@ class TestRequestShaping:
|
|||||||
assert "enable_thinking" not in seen
|
assert "enable_thinking" not in seen
|
||||||
assert seen["stream_options"] == {"include_usage": True}
|
assert seen["stream_options"] == {"include_usage": True}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("enable_thinking", "expected"),
|
||||||
|
[(True, "medium"), (False, "none")],
|
||||||
|
)
|
||||||
|
async def test_minimax_injects_reasoning_effort(self, enable_thinking, expected):
|
||||||
|
"""issue #5: MiniMax 认的是 reasoning_effort,不是 enable_thinking。"""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.update(json.loads(request.content))
|
||||||
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
source = _source(
|
||||||
|
name="mm", provider="minimax", model="MiniMax-M3", enable_thinking=enable_thinking
|
||||||
|
)
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
assert seen["reasoning_effort"] == expected
|
||||||
|
assert "enable_thinking" not in seen # 旧形态实测被静默丢弃,不再下发
|
||||||
|
|
||||||
|
async def test_extra_body_overrides_the_profile_slot(self):
|
||||||
|
"""注入顺序即优先级: profile → extra_body → overlay,两行不可调换。"""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.update(json.loads(request.content))
|
||||||
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
source = _source(
|
||||||
|
name="mm",
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M3",
|
||||||
|
enable_thinking=True,
|
||||||
|
extra_body={"reasoning_effort": "high"},
|
||||||
|
)
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
assert seen["reasoning_effort"] == "high"
|
||||||
|
|
||||||
|
async def test_model_that_cannot_disable_is_rejected_not_silently_ignored(self):
|
||||||
|
"""M2.x 关不掉推理: 必须是四分类之一的 RequestRejected,不是裸 ValueError。
|
||||||
|
|
||||||
|
裸异常会逃出 chat() —— 它不属错误四分类、TelemetryMW 也不捕,结果是一行
|
||||||
|
遥测都没有就崩了(设计 §5.1)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
source = _source(name="mm", provider="minimax", model="MiniMax-M2.7", enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="MiniMax-M2.7"):
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
|
||||||
|
async def test_unregistered_model_warns_only_once_per_source(self):
|
||||||
|
"""未登记模型的告警不能打在请求热路径上: 装配期已喊过,逐次再喊是刷屏。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
source = _source(name="mm", provider="minimax", model="MiniMax-M99", enable_thinking=False)
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
await _complete(transport, source)
|
||||||
|
await _complete(transport, source)
|
||||||
|
await _complete(transport, source)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
hits = [m for m in messages if "MiniMax-M99" in m]
|
||||||
|
assert len(hits) == 1, f"三次调用应只告警一次,实得 {len(hits)} 次"
|
||||||
|
|
||||||
|
async def test_unrelated_value_error_is_not_mislabelled(self, monkeypatch):
|
||||||
|
"""只捕 ThinkingUnsupportedError: 无关的 ValueError 不该被贴成推理开关的错。
|
||||||
|
|
||||||
|
今天 `_build_payload` 里只有 resolve_thinking 会抛 ValueError,所以这条
|
||||||
|
是防御未来 —— 但正因如此才要钉住: 将来谁在那里加一处校验,宽 catch 会
|
||||||
|
把它的错误信息盖掉,而这个用例会先红。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise ValueError("故意的无关错误")
|
||||||
|
|
||||||
|
monkeypatch.setattr("polygateway.transports.openai_compat.resolve_thinking", _boom)
|
||||||
|
with pytest.raises(ValueError, match="故意的无关错误") as exc:
|
||||||
|
await _complete(_transport_for(handler), _source(enable_thinking=False))
|
||||||
|
assert "推理开关" not in str(exc.value)
|
||||||
|
assert not isinstance(exc.value, RequestRejectedError)
|
||||||
|
|
||||||
|
async def test_unknown_shape_is_rejected(self):
|
||||||
|
def handler(request): # pragma: no cover - 不该走到发请求
|
||||||
|
raise AssertionError("请求不该发出")
|
||||||
|
|
||||||
|
source = _source(name="k3", provider="openai", model="kimi-k3", enable_thinking=False)
|
||||||
|
with pytest.raises(RequestRejectedError, match="register_provider"):
|
||||||
|
await _complete(_transport_for(handler), source)
|
||||||
|
|
||||||
async def test_overlay_merged_into_payload(self):
|
async def test_overlay_merged_into_payload(self):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
@@ -312,6 +619,27 @@ class TestRequestShaping:
|
|||||||
)
|
)
|
||||||
assert seen["response_format"] == {"type": "json_object"}
|
assert seen["response_format"] == {"type": "json_object"}
|
||||||
|
|
||||||
|
async def test_extra_body_merged_and_outranked_by_overlay(self):
|
||||||
|
"""顺序即优先级: thinking profile → extra_body → overlay(issue #4)。"""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.update(json.loads(request.content))
|
||||||
|
return _sse_stream(_chunk(content="x"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
await _complete(
|
||||||
|
_transport_for(handler),
|
||||||
|
_source(extra_body={"temperature": 0, "top_p": 0.9}),
|
||||||
|
overlay={"temperature": 1},
|
||||||
|
)
|
||||||
|
assert seen["temperature"] == 1 # 调用级覆盖配置级
|
||||||
|
assert seen["top_p"] == 0.9 # 未被顶掉的配置级键保留
|
||||||
|
|
||||||
|
async def test_extra_body_cannot_break_governed_keys(self):
|
||||||
|
"""治理键由 payload 骨架拥有;extra_body 的保护键在构造期已被拦下。"""
|
||||||
|
with pytest.raises(ValueError, match="stream"):
|
||||||
|
_source(extra_body={"stream": False})
|
||||||
|
|
||||||
|
|
||||||
class TestErrorTranslation:
|
class TestErrorTranslation:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ class _DummyRecorder:
|
|||||||
cache_hit,
|
cache_hit,
|
||||||
error,
|
error,
|
||||||
cost,
|
cost,
|
||||||
|
cached_prompt_tokens,
|
||||||
|
model_reported,
|
||||||
|
sampling,
|
||||||
|
reasoning_tokens,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,74 @@ class TestPricingTable:
|
|||||||
ModelPrice(input_per_1m=-1.0, output_per_1m=0.0)
|
ModelPrice(input_per_1m=-1.0, output_per_1m=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachedInputTier:
|
||||||
|
"""issue #3: 供应商 prompt cache 命中部分按更低单价计费,不配则不猜折扣。"""
|
||||||
|
|
||||||
|
_CACHED = PricingTable(
|
||||||
|
{"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0, cached_input_per_1m=2.0)}
|
||||||
|
)
|
||||||
|
_PLAIN = PricingTable({"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0)})
|
||||||
|
|
||||||
|
def test_hit_is_billed_at_the_cached_rate(self):
|
||||||
|
# 1M prompt 中 600k 命中: 400k×10 + 600k×2 = 4.0 + 1.2
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, 600_000) == pytest.approx(5.2)
|
||||||
|
|
||||||
|
def test_without_the_tier_the_result_is_unchanged(self):
|
||||||
|
"""未配缓存档 = 退化为现状全额计价,绝不按经验折扣率猜(P5)。"""
|
||||||
|
full = self._PLAIN.cost("m", 1_000_000, 0)
|
||||||
|
assert self._PLAIN.cost("m", 1_000_000, 0, 600_000) == full == pytest.approx(10.0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cached", [None, 0])
|
||||||
|
def test_no_hit_is_billed_in_full(self, cached):
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, cached) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_negative_cached_is_billed_in_full(self):
|
||||||
|
"""负数命中数不得抬高成本: cost() 是公共方法,外部输入须校验后使用(P5)。"""
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0, -500_000) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_cached_over_prompt_is_clamped_and_never_negative(self):
|
||||||
|
"""网关口径异常时按输入总数夹取: 全部按缓存价,不得算出负成本。"""
|
||||||
|
clamped = self._CACHED.cost("m", 1_000_000, 0, 5_000_000)
|
||||||
|
assert clamped == pytest.approx(2.0) and clamped >= 0
|
||||||
|
|
||||||
|
def test_legacy_three_arg_call_still_works(self):
|
||||||
|
"""embedding.py 的三参调用形态必须零改动可用。"""
|
||||||
|
assert self._CACHED.cost("m", 1_000_000, 0) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
def test_from_file_accepts_and_validates_the_tier(self, tmp_path):
|
||||||
|
path = tmp_path / "p.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"m": {"input_per_1m": 10.0, "output_per_1m": 20.0, "cached_input_per_1m": 2.0}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert PricingTable.from_file(path).cost("m", 1_000_000, 0, 1_000_000) == pytest.approx(2.0)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [-1.0, "x"])
|
||||||
|
def test_from_file_rejects_a_bad_tier(self, tmp_path, bad):
|
||||||
|
path = tmp_path / "bad.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"m": {"input_per_1m": 1.0, "output_per_1m": 2.0, "cached_input_per_1m": bad}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="cached_input_per_1m"):
|
||||||
|
PricingTable.from_file(path)
|
||||||
|
|
||||||
|
def test_legacy_price_file_without_the_tier_still_loads(self, tmp_path):
|
||||||
|
path = tmp_path / "old.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert PricingTable.from_file(path).cost("m", 1_000_000, 0, 500_000) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
def test_negative_tier_rejected_on_construction(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ModelPrice(input_per_1m=1.0, output_per_1m=1.0, cached_input_per_1m=-0.1)
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rows = []
|
self.rows = []
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
"""providers.py 注册表测试(M1 设计 §7;register_provider 为纯函数,无可变全局)。"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.providers import (
|
from polygateway.providers import (
|
||||||
|
DEFAULT_CAPABILITIES,
|
||||||
DEFAULT_PROFILES,
|
DEFAULT_PROFILES,
|
||||||
ProviderProfile,
|
ProviderProfile,
|
||||||
|
ThinkingCapability,
|
||||||
|
get_capability,
|
||||||
get_provider,
|
get_provider,
|
||||||
|
register_capability,
|
||||||
register_provider,
|
register_provider,
|
||||||
|
resolve_thinking,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,14 +30,21 @@ class TestDefaultProfiles:
|
|||||||
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
assert p.thinking_off == {"thinking": {"type": "disabled"}}
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_openai_baseline_profile(self):
|
def test_openai_slots_are_unknown_not_empty(self):
|
||||||
|
"""issue #5: 该段名实践中被复用为任意兼容厂商的兜底(下游把 kimi 挂在此),
|
||||||
|
|
||||||
|
故不能下发任何厂商方言参数。None = 形态未知 → 配了 enable_thinking 即报错,
|
||||||
|
而不是空字典那种"注入了个寂寞"的静默失效。
|
||||||
|
"""
|
||||||
p = get_provider("openai")
|
p = get_provider("openai")
|
||||||
assert p.thinking_on == {} and p.thinking_off == {}
|
assert p.thinking_on is None and p.thinking_off is None
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_minimax_baseline_profile(self):
|
def test_minimax_profile_uses_reasoning_effort(self):
|
||||||
|
"""2026-08-02 实测: reasoning_effort 才是 MiniMax 认的开关。"""
|
||||||
p = get_provider("minimax")
|
p = get_provider("minimax")
|
||||||
assert p.thinking_on == {} and p.thinking_off == {}
|
assert p.thinking_off == {"reasoning_effort": "none"}
|
||||||
|
assert p.thinking_on == {"reasoning_effort": "medium"}
|
||||||
assert p.strip_think_tags is False
|
assert p.strip_think_tags is False
|
||||||
|
|
||||||
def test_unknown_provider_fails_loudly(self):
|
def test_unknown_provider_fails_loudly(self):
|
||||||
@@ -62,3 +75,83 @@ class TestPureFunctionRegistration:
|
|||||||
def test_default_profiles_mapping_is_read_only(self):
|
def test_default_profiles_mapping_is_read_only(self):
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
DEFAULT_PROFILES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
return messages, sink_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestThinkingCapability:
|
||||||
|
"""issue #5: 能力按 model 登记——同一 provider 内部代际差异是决定性的。"""
|
||||||
|
|
||||||
|
def test_registered_models_carry_evidence(self):
|
||||||
|
"""登记必须附实测证据: 表会过期,没有出处就无从判断该不该信。"""
|
||||||
|
for model in ("MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"):
|
||||||
|
cap = get_capability(model)
|
||||||
|
assert cap is not None and cap.evidence.strip()
|
||||||
|
|
||||||
|
def test_m3_can_disable_but_m2x_cannot(self):
|
||||||
|
assert get_capability("MiniMax-M3").can_disable is True
|
||||||
|
assert get_capability("MiniMax-M2.7").can_disable is False
|
||||||
|
assert get_capability("MiniMax-M2.5").can_disable is False
|
||||||
|
|
||||||
|
def test_unregistered_model_is_unknown(self):
|
||||||
|
assert get_capability("some-brand-new-model") is None
|
||||||
|
|
||||||
|
def test_register_capability_is_pure(self):
|
||||||
|
table = register_capability("x-1", ThinkingCapability(True, "实测"))
|
||||||
|
assert get_capability("x-1", table=table) is not None
|
||||||
|
assert get_capability("x-1") is None # 默认表未被污染
|
||||||
|
|
||||||
|
def test_default_capabilities_mapping_is_read_only(self):
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
DEFAULT_CAPABILITIES["hack"] = None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveThinking:
|
||||||
|
"""五条判定规则(顺序即语义);设计 §5 真值表。"""
|
||||||
|
|
||||||
|
def test_rule1_none_injects_nothing(self):
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, None, model="MiniMax-M3")
|
||||||
|
assert got == {}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("enable", [True, False])
|
||||||
|
def test_rule2_unknown_shape_raises_and_points_the_way(self, enable):
|
||||||
|
with pytest.raises(ValueError, match="register_provider") as exc:
|
||||||
|
resolve_thinking(get_provider("openai"), None, enable, model="kimi-k3")
|
||||||
|
assert "extra_body" in str(exc.value)
|
||||||
|
|
||||||
|
def test_rule3_unregistered_model_warns_but_passes(self):
|
||||||
|
messages, sink_id = _warnings()
|
||||||
|
try:
|
||||||
|
got = resolve_thinking(get_provider("minimax"), None, False, model="MiniMax-M9")
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert got == {"reasoning_effort": "none"}
|
||||||
|
assert any("MiniMax-M9" in m for m in messages)
|
||||||
|
|
||||||
|
def test_rule4_cannot_disable_raises_with_the_model_name(self):
|
||||||
|
cap = get_capability("MiniMax-M2.7")
|
||||||
|
with pytest.raises(ValueError, match="MiniMax-M2.7"):
|
||||||
|
resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M2.7")
|
||||||
|
|
||||||
|
def test_rule4_only_blocks_the_off_direction(self):
|
||||||
|
"""关不掉 ≠ 开不了: M2.x 默认就在推理,开的方向不该被拦。"""
|
||||||
|
cap = get_capability("MiniMax-M2.7")
|
||||||
|
got = resolve_thinking(get_provider("minimax"), cap, True, model="MiniMax-M2.7")
|
||||||
|
assert got == {"reasoning_effort": "medium"}
|
||||||
|
|
||||||
|
def test_rule5_normal_path(self):
|
||||||
|
cap = get_capability("MiniMax-M3")
|
||||||
|
assert resolve_thinking(get_provider("minimax"), cap, False, model="MiniMax-M3") == {
|
||||||
|
"reasoning_effort": "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_unknown_shape_beats_capability_check(self):
|
||||||
|
"""第 2 步先于第 4 步: 形态未知时无从注入,能力如何无关紧要。"""
|
||||||
|
cap = ThinkingCapability(can_disable=False, evidence="构造")
|
||||||
|
with pytest.raises(ValueError, match="register_provider"):
|
||||||
|
resolve_thinking(get_provider("openai"), cap, False, model="whatever")
|
||||||
|
|||||||
@@ -194,6 +194,38 @@ class TestSuccessPath:
|
|||||||
assert (await limiter.source_stats("a")).tpm_used == 16
|
assert (await limiter.source_stats("a")).tpm_used == 16
|
||||||
|
|
||||||
|
|
||||||
|
class TestObservabilityPassthrough:
|
||||||
|
"""issue #3: transport 采到的两个可观测字段必须原样上浮到 LLMResponse。"""
|
||||||
|
|
||||||
|
async def test_fields_reach_the_response(self):
|
||||||
|
result = TransportResult(
|
||||||
|
content="ok",
|
||||||
|
thinking="",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=5,
|
||||||
|
usage_source="measured",
|
||||||
|
ttft_ms=12.0,
|
||||||
|
max_inter_token_ms=3.0,
|
||||||
|
raw={},
|
||||||
|
cached_prompt_tokens=64,
|
||||||
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
reasoning_tokens=7,
|
||||||
|
)
|
||||||
|
mw, *_ = _harness([_src("a")], [result])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.cached_prompt_tokens == 64
|
||||||
|
assert resp.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
assert resp.reasoning_tokens == 7
|
||||||
|
# model 仍是配置别名: 真实版本是旁证,不顶替溯源主字段
|
||||||
|
assert resp.model == "m"
|
||||||
|
|
||||||
|
async def test_absent_fields_stay_none(self):
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
resp = await mw(_REQ)
|
||||||
|
assert resp.cached_prompt_tokens is None and resp.model_reported is None
|
||||||
|
assert resp.reasoning_tokens is None
|
||||||
|
|
||||||
|
|
||||||
class TestRetryAndFailover:
|
class TestRetryAndFailover:
|
||||||
async def test_transient_switches_source_then_succeeds(self):
|
async def test_transient_switches_source_then_succeeds(self):
|
||||||
mw, _, _, transport, sleep, _ = _harness(
|
mw, _, _, transport, sleep, _ = _harness(
|
||||||
|
|||||||
@@ -177,3 +177,55 @@ class TestNativeOverlayFirstAttempt:
|
|||||||
resp = await _mw()(ChatRequest(messages=_MSGS, structured=Verdict), terminal)
|
resp = await _mw()(ChatRequest(messages=_MSGS, structured=Verdict), terminal)
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
resp.structured_data = None
|
resp.structured_data = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplingSnapshotInvariant:
|
||||||
|
"""地基不变式: `sampling` 跨洋葱层恒定,`overlay` 会被结构化注入(issue #4)。
|
||||||
|
|
||||||
|
缓存 key(决策 C)与三个遥测入口(决策 D)都建立在这条之上,而它此前只靠
|
||||||
|
"dataclasses.replace 恰好保留未提及字段"的约定成立,无任何机械执法。
|
||||||
|
这个测试是那份执法——它红了就意味着两个决策同时失效。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_sampling_survives_feedback_ladder_while_overlay_diverges(self):
|
||||||
|
caller_sampling = {"temperature": 0, "seed": 42}
|
||||||
|
# 先坏后好,强制走一次带反馈重问(重问会 replace messages)
|
||||||
|
terminal = ScriptedTerminal(["not json at all", '{"answer": 1, "reason": "r"}'])
|
||||||
|
mw = _mw(strategy=NativeSchemaStrategy(), max_retries=1)
|
||||||
|
await mw(
|
||||||
|
# overlay 与 sampling 传**同一个对象**,复现 client.py 的别名关系
|
||||||
|
# ——否则中间件就地改写 overlay 时不会波及 sampling,这条执法就是空的
|
||||||
|
ChatRequest(
|
||||||
|
messages=_MSGS,
|
||||||
|
structured=Verdict,
|
||||||
|
overlay=caller_sampling,
|
||||||
|
sampling=caller_sampling,
|
||||||
|
),
|
||||||
|
terminal,
|
||||||
|
)
|
||||||
|
assert len(terminal.requests) == 2 # 确实重问过
|
||||||
|
for seen in terminal.requests:
|
||||||
|
# ① 跨层恒定: 每次尝试看到的 sampling 与调用方传入的逐字相同
|
||||||
|
assert seen.sampling == caller_sampling
|
||||||
|
# ② 确实分叉: 同一时刻 overlay 已被注入 response_format
|
||||||
|
assert seen.overlay["response_format"]["type"] == "json_schema"
|
||||||
|
assert "response_format" not in seen.sampling
|
||||||
|
|
||||||
|
async def test_middleware_does_not_mutate_caller_mapping(self):
|
||||||
|
"""决策 E 的第二条约束: 中间件只能 replace 派生,不得就地改这两个 dict。
|
||||||
|
|
||||||
|
同样传同一对象: 生产中 overlay 与 sampling 是别名,任何对 overlay 的
|
||||||
|
就地改写都会同步毒化缓存 key 与遥测列。
|
||||||
|
"""
|
||||||
|
caller_sampling = {"seed": 7}
|
||||||
|
terminal = ScriptedTerminal(['{"answer": 1, "reason": "r"}'])
|
||||||
|
await _mw(strategy=NativeSchemaStrategy())(
|
||||||
|
ChatRequest(
|
||||||
|
messages=_MSGS,
|
||||||
|
structured=Verdict,
|
||||||
|
overlay=caller_sampling,
|
||||||
|
sampling=caller_sampling,
|
||||||
|
),
|
||||||
|
terminal,
|
||||||
|
)
|
||||||
|
assert caller_sampling == {"seed": 7} # 调用方的对象未被污染
|
||||||
|
|||||||
+440
-10
@@ -1,6 +1,7 @@
|
|||||||
"""遥测子系统测试: SQLiteRecorder(18 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
"""遥测子系统测试: SQLiteRecorder(22 列)+ TelemetryEmitter 单一 helper + TelemetryMW。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -35,6 +36,10 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error",
|
"error",
|
||||||
"cost",
|
"cost",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -58,15 +63,17 @@ def _resp(**overrides):
|
|||||||
return LLMResponse(**base)
|
return LLMResponse(**base)
|
||||||
|
|
||||||
|
|
||||||
def _source():
|
def _source(**overrides):
|
||||||
return SourceConfig(
|
base = {
|
||||||
name="s1",
|
"name": "s1",
|
||||||
provider="p",
|
"provider": "p",
|
||||||
base_url="https://gw.example/v1",
|
"base_url": "https://gw.example/v1",
|
||||||
api_key="sk",
|
"api_key": "sk",
|
||||||
model="m",
|
"model": "m",
|
||||||
timeout_s=10.0,
|
"timeout_s": 10.0,
|
||||||
)
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return SourceConfig(**base)
|
||||||
|
|
||||||
|
|
||||||
# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032
|
# 输出单价 8 元/百万: 改前 `unavailable` 行按兜底的 0/4000 换算恰好是 0.032
|
||||||
@@ -93,6 +100,10 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
"cache_hit": False,
|
"cache_hit": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
"cost": None,
|
"cost": None,
|
||||||
|
"cached_prompt_tokens": None,
|
||||||
|
"model_reported": None,
|
||||||
|
"sampling": None,
|
||||||
|
"reasoning_tokens": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -134,6 +145,198 @@ class TestSQLiteRecorder:
|
|||||||
await _record_minimal(recorder) # 不抛
|
await _record_minimal(recorder) # 不抛
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
|
async def test_observability_columns_round_trip(self, tmp_path):
|
||||||
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
|
await _record_minimal(recorder, call_id="c-hit", cached_prompt_tokens=64)
|
||||||
|
await _record_minimal(recorder, call_id="c-zero", cached_prompt_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id="c-none", model_reported="MiniMax-Text-01")
|
||||||
|
recorder.close()
|
||||||
|
rows = dict(
|
||||||
|
sqlite3.connect(tmp_path / "t.db")
|
||||||
|
.execute("SELECT call_id, cached_prompt_tokens FROM llm_calls")
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
assert rows["c-hit"] == 64
|
||||||
|
assert rows["c-zero"] == 0 # 真实零命中,读回仍是 0 而非 NULL
|
||||||
|
assert rows["c-none"] is None
|
||||||
|
|
||||||
|
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
|
||||||
|
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
|
||||||
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
|
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
|
||||||
|
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
|
||||||
|
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
|
||||||
|
recorder.close()
|
||||||
|
rows = dict(
|
||||||
|
sqlite3.connect(tmp_path / "t.db")
|
||||||
|
.execute("SELECT call_id, reasoning_tokens FROM llm_calls")
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
assert rows["r-some"] == 7
|
||||||
|
assert rows["r-zero"] == 0 # 上报了且确实没推理
|
||||||
|
assert rows["r-none"] is None # 本次调用未上报
|
||||||
|
|
||||||
|
async def test_sampling_column_round_trips(self, tmp_path):
|
||||||
|
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
||||||
|
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||||
|
await _record_minimal(recorder, call_id="c-s", sampling='{"seed": 42, "temperature": 0}')
|
||||||
|
await _record_minimal(recorder, call_id="c-plain")
|
||||||
|
recorder.close()
|
||||||
|
rows = dict(
|
||||||
|
sqlite3.connect(tmp_path / "t.db")
|
||||||
|
.execute("SELECT call_id, sampling FROM llm_calls")
|
||||||
|
.fetchall()
|
||||||
|
)
|
||||||
|
assert json.loads(rows["c-s"]) == {"seed": 42, "temperature": 0}
|
||||||
|
assert rows["c-plain"] is None # 无采样参数为 NULL,便于 SQL 过滤
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLiteColumnBackfill:
|
||||||
|
"""issue #3: 已存在的 18 列旧表必须自动补列,否则每行写入都被丢弃。"""
|
||||||
|
|
||||||
|
_LEGACY_DDL = """
|
||||||
|
CREATE TABLE llm_calls (
|
||||||
|
call_id TEXT PRIMARY KEY,
|
||||||
|
parent_call_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
messages TEXT NOT NULL,
|
||||||
|
response TEXT NOT NULL,
|
||||||
|
thinking TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INTEGER NOT NULL,
|
||||||
|
completion_tokens INTEGER NOT NULL,
|
||||||
|
usage_source TEXT NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
ttft_ms REAL,
|
||||||
|
max_inter_token_ms REAL,
|
||||||
|
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
cost REAL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_legacy_table_is_upgraded_in_place(self, tmp_path):
|
||||||
|
db = tmp_path / "legacy.db"
|
||||||
|
legacy = sqlite3.connect(db)
|
||||||
|
legacy.execute(self._LEGACY_DDL)
|
||||||
|
legacy.commit()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db)
|
||||||
|
await _record_minimal(recorder, cached_prompt_tokens=7, model_reported="m-real")
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||||
|
assert cols == _EXPECTED_COLUMNS # ALTER 追加到末尾,与新建库列序一致
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT cached_prompt_tokens, model_reported FROM llm_calls"
|
||||||
|
).fetchone() == (7, "m-real")
|
||||||
|
|
||||||
|
async def test_backfill_failure_keeps_the_recorder_usable(self, tmp_path):
|
||||||
|
"""补列失败只能逐行降级,绝不能把 recorder 整体变成 no-op(设计 D1 纪律)。
|
||||||
|
|
||||||
|
把 llm_calls 建成同名 view: `CREATE TABLE IF NOT EXISTS` 遇 view 静默
|
||||||
|
no-op(不抛),随后的 ALTER 才抛 "Cannot add a column to a view"——正是
|
||||||
|
补列失败这条分支。`_conn` 必须保持非 None,否则整个 recorder 永久失能。
|
||||||
|
"""
|
||||||
|
db = tmp_path / "view.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("CREATE TABLE real_rows (call_id TEXT)")
|
||||||
|
conn.execute("CREATE VIEW llm_calls AS SELECT call_id FROM real_rows")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db) # 不得抛
|
||||||
|
assert recorder._conn is not None # 补列失败 ≠ recorder 失能(D1 纪律)
|
||||||
|
await _record_minimal(recorder) # 不得抛
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePgConn:
|
||||||
|
"""记录执行过的语句;可让 ALTER 抛错以模拟权限不足。"""
|
||||||
|
|
||||||
|
def __init__(self, existing: list[str], *, fail_alter: bool = False):
|
||||||
|
self.existing = existing
|
||||||
|
self.fail_alter = fail_alter
|
||||||
|
self.statements: list[str] = []
|
||||||
|
|
||||||
|
async def execute(self, sql, *args):
|
||||||
|
self.statements.append(sql)
|
||||||
|
if sql.startswith("ALTER TABLE") and self.fail_alter:
|
||||||
|
raise RuntimeError("must be owner of table llm_calls")
|
||||||
|
|
||||||
|
async def fetch(self, sql, *args):
|
||||||
|
self.statements.append(sql)
|
||||||
|
return [{"attname": name} for name in self.existing]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePgPool:
|
||||||
|
def __init__(self, conn):
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
|
def acquire(self):
|
||||||
|
conn = self._conn
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return conn
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return _Ctx()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostgresBackfillDiscipline:
|
||||||
|
"""PG 补列必须与 SQLite 侧对称: 失败只逐行降级,且稳态不抢排他锁(issue #3)。"""
|
||||||
|
|
||||||
|
_LEGACY = ["call_id", "cost", "created_at"]
|
||||||
|
_CURRENT = [
|
||||||
|
"call_id",
|
||||||
|
"cost",
|
||||||
|
"created_at",
|
||||||
|
"cached_prompt_tokens",
|
||||||
|
"model_reported",
|
||||||
|
"sampling",
|
||||||
|
"reasoning_tokens",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _recorder(self, conn):
|
||||||
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
|
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
|
||||||
|
|
||||||
|
async def test_alter_failure_does_not_disable_the_recorder(self):
|
||||||
|
"""ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。"""
|
||||||
|
conn = _FakePgConn(self._LEGACY, fail_alter=True)
|
||||||
|
recorder = self._recorder(conn)
|
||||||
|
await _record_minimal(recorder) # 不得抛
|
||||||
|
assert recorder._failed is False
|
||||||
|
assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements)
|
||||||
|
|
||||||
|
async def test_no_alter_when_columns_already_exist(self):
|
||||||
|
"""ADD COLUMN IF NOT EXISTS 即使列已存在也会先抢 ACCESS EXCLUSIVE 锁,
|
||||||
|
|
||||||
|
而遥测是内联 await——稳态下必须一条 ALTER 都不发,否则每个进程的首次
|
||||||
|
写入都会去锁共享审计表。
|
||||||
|
"""
|
||||||
|
conn = _FakePgConn(self._CURRENT)
|
||||||
|
await _record_minimal(self._recorder(conn))
|
||||||
|
assert not [s for s in conn.statements if s.startswith("ALTER TABLE")]
|
||||||
|
|
||||||
|
async def test_missing_columns_are_added_once(self):
|
||||||
|
conn = _FakePgConn(self._LEGACY)
|
||||||
|
await _record_minimal(self._recorder(conn))
|
||||||
|
from polygateway.telemetry.postgres import _BACKFILL
|
||||||
|
|
||||||
|
altered = [s for s in conn.statements if s.startswith("ALTER TABLE")]
|
||||||
|
assert len(altered) == len(_BACKFILL) # 旧表缺全部补列,故一列一条 ALTER
|
||||||
|
assert all("IF NOT EXISTS" not in s for s in altered) # 探测已确认缺列,无需再判
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -143,6 +346,233 @@ class _MemoryRecorder:
|
|||||||
self.rows.append(fields)
|
self.rows.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitterRecorderContract:
|
||||||
|
"""emitter 的实参键集合必须与两个后端的 _COLUMNS 完全一致(issue #3)。
|
||||||
|
|
||||||
|
两个后端的 `row = tuple(fields[col] for col in _COLUMNS)` 都在 try **之外**,
|
||||||
|
emitter 漏传一个键就抛 KeyError,被 `_record` 的 except Exception 吞成 warning
|
||||||
|
→ 遥测静默全丢。而 8 个 `**fields` 形态的 fake 一个都拦不住,故显式断言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_emitter_supplies_exactly_the_backend_columns(self):
|
||||||
|
from polygateway.telemetry.postgres import _COLUMNS as PG_COLUMNS
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||||
|
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-1",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert set(rec.rows[0]) == set(SQLITE_COLUMNS) == set(PG_COLUMNS)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("emit", ["attempt", "cache_hit", "terminal_failure"])
|
||||||
|
async def test_every_entry_point_supplies_the_same_keys(self, emit):
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as SQLITE_COLUMNS
|
||||||
|
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec)
|
||||||
|
if emit == "attempt":
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
)
|
||||||
|
elif emit == "cache_hit":
|
||||||
|
await emitter.emit_cache_hit(request=_REQ, response=_resp())
|
||||||
|
else:
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert set(rec.rows[0]) == set(SQLITE_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitterObservabilityFields:
|
||||||
|
"""issue #3: 三个入口各自的取值口径(设计 §5 表)。"""
|
||||||
|
|
||||||
|
async def test_attempt_carries_the_response_values(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-1",
|
||||||
|
latency_ms=42,
|
||||||
|
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] == 64
|
||||||
|
assert rec.rows[0]["model_reported"] == "m-real"
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] == 7
|
||||||
|
|
||||||
|
async def test_failed_attempt_has_no_provider_facts(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="cid-2",
|
||||||
|
latency_ms=7,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] is None
|
||||||
|
|
||||||
|
async def test_cache_hit_replays_the_recorded_values(self):
|
||||||
|
"""决策 B1: 命中行原样回放,故命中率统计必须带 WHERE cache_hit = false。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_cache_hit(
|
||||||
|
request=_REQ,
|
||||||
|
response=_resp(cached_prompt_tokens=64, model_reported="m-real", reasoning_tokens=7),
|
||||||
|
)
|
||||||
|
row = rec.rows[0]
|
||||||
|
assert row["cache_hit"] is True
|
||||||
|
assert row["cached_prompt_tokens"] == 64 and row["model_reported"] == "m-real"
|
||||||
|
assert row["reasoning_tokens"] == 7 # 与 cached 同口径原样回放
|
||||||
|
|
||||||
|
async def test_terminal_failure_records_none(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cached_prompt_tokens"] is None
|
||||||
|
assert rec.rows[0]["model_reported"] is None
|
||||||
|
assert rec.rows[0]["reasoning_tokens"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitterSamplingColumn:
|
||||||
|
"""issue #4: sampling 列在三个入口的口径(设计决策 D 表格)。
|
||||||
|
|
||||||
|
列语义 = 「调用方采样意图 ⊎ 生效源 extra_body」,**不含**结构化注入的
|
||||||
|
response_format(列名是采样参数,schema 不是;且数 KB schema 逐行落库会让
|
||||||
|
审计表无谓膨胀)。三入口若各读各的层,同一列在不同行含义就不同。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SAMPLED = ChatRequest(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
sampling={"seed": 42},
|
||||||
|
overlay={"seed": 42, "response_format": {"type": "json_object"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_attempt_merges_source_extra_body(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=self._SAMPLED,
|
||||||
|
source=_source(extra_body={"temperature": 0}),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42, "temperature": 0}
|
||||||
|
|
||||||
|
async def test_response_format_never_leaks_into_the_column(self):
|
||||||
|
"""三行都不得出现 response_format——它不是采样参数。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec)
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=self._SAMPLED,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp())
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=self._SAMPLED, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert len(rec.rows) == 3
|
||||||
|
for row in rec.rows:
|
||||||
|
assert "response_format" not in row["sampling"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("emit", ["cache_hit", "terminal_failure"])
|
||||||
|
async def test_sourceless_entries_record_call_level_only(self, emit):
|
||||||
|
"""两个最外层入口没有"生效源"可言,与 model/source_name 置空同一先例。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec)
|
||||||
|
if emit == "cache_hit":
|
||||||
|
await emitter.emit_cache_hit(request=self._SAMPLED, response=_resp())
|
||||||
|
else:
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=self._SAMPLED, call_id="c", latency_ms=1, error="dead"
|
||||||
|
)
|
||||||
|
assert json.loads(rec.rows[0]["sampling"]) == {"seed": 42}
|
||||||
|
|
||||||
|
async def test_absent_sampling_is_null(self):
|
||||||
|
"""无采样参数时为 NULL,而非空字符串或 "{}"——便于 SQL 过滤。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["sampling"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCostWithCachedTier:
|
||||||
|
"""issue #3: 命中部分按缓存单价计费,避免 cost 系统性高估。"""
|
||||||
|
|
||||||
|
_TABLE = PricingTable(
|
||||||
|
{"m": ModelPrice(input_per_1m=10.0, output_per_1m=20.0, cached_input_per_1m=2.0)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_cached_hit_lowers_the_recorded_cost(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec, pricing=self._TABLE)
|
||||||
|
full = _resp(prompt_tokens=1_000_000, completion_tokens=0)
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c1",
|
||||||
|
latency_ms=1,
|
||||||
|
response=full,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c2",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(
|
||||||
|
prompt_tokens=1_000_000, completion_tokens=0, cached_prompt_tokens=600_000
|
||||||
|
),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] == pytest.approx(10.0)
|
||||||
|
assert rec.rows[1]["cost"] == pytest.approx(5.2) # 400k×10 + 600k×2
|
||||||
|
|
||||||
|
async def test_cache_hit_row_still_costs_zero(self):
|
||||||
|
"""缓存命中未产生新调用 → cost 恒 0.0,该短路必须排在任何换算之前。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=self._TABLE).emit_cache_hit(
|
||||||
|
request=_REQ,
|
||||||
|
response=_resp(prompt_tokens=1_000_000, cached_prompt_tokens=600_000),
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] == 0.0
|
||||||
|
|
||||||
|
async def test_unavailable_usage_still_costs_none(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, pricing=self._TABLE).emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(usage_source="unavailable", cached_prompt_tokens=5),
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["cost"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestEmitter:
|
class TestEmitter:
|
||||||
async def test_attempt_success_row(self):
|
async def test_attempt_success_row(self):
|
||||||
rec = _MemoryRecorder()
|
rec = _MemoryRecorder()
|
||||||
|
|||||||
@@ -49,6 +49,32 @@ class TestLLMResponse:
|
|||||||
assert resp.usage_source == "measured"
|
assert resp.usage_source == "measured"
|
||||||
assert resp.structured_data is None
|
assert resp.structured_data is None
|
||||||
|
|
||||||
|
def test_observability_fields_default_to_none(self):
|
||||||
|
"""issue #3: None = 该源未上报,与"上报了但是 0"区分(0 是真实零命中)。"""
|
||||||
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
|
assert resp.cached_prompt_tokens is None
|
||||||
|
assert resp.model_reported is None
|
||||||
|
assert resp.reasoning_tokens is None # issue #6: 本次调用未上报
|
||||||
|
filled = LLMResponse(
|
||||||
|
"c",
|
||||||
|
"t",
|
||||||
|
"m",
|
||||||
|
"p",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
"cid",
|
||||||
|
cached_prompt_tokens=0,
|
||||||
|
model_reported="MiniMax-Text-01-250321",
|
||||||
|
reasoning_tokens=0,
|
||||||
|
)
|
||||||
|
assert filled.cached_prompt_tokens == 0 # 真实零命中,不得与 None 混同
|
||||||
|
assert filled.model_reported == "MiniMax-Text-01-250321"
|
||||||
|
assert filled.reasoning_tokens == 0 # 上报了且确实没推理,不得与 None 混同
|
||||||
|
|
||||||
def test_frozen(self):
|
def test_frozen(self):
|
||||||
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
resp = LLMResponse("c", "t", "m", "p", 1, 2, 3, None, None, False, "cid")
|
||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
@@ -222,6 +248,9 @@ class TestAuxTypes:
|
|||||||
raw={"id": "x"},
|
raw={"id": "x"},
|
||||||
)
|
)
|
||||||
assert s.raw["id"] == "x"
|
assert s.raw["id"] == "x"
|
||||||
|
# issue #3: 新字段带默认值,不填也能构造(OCR 等其他 transport 零改动)
|
||||||
|
assert s.cached_prompt_tokens is None and s.model_reported is None
|
||||||
|
assert s.reasoning_tokens is None
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
@@ -280,3 +309,92 @@ class TestOcrTypes:
|
|||||||
|
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
OcrTextResult(text="x") # 溯源件不可省略
|
OcrTextResult(text="x") # 溯源件不可省略
|
||||||
|
|
||||||
|
|
||||||
|
class TestSamplingValidation:
|
||||||
|
"""采样参数覆盖层的构造期校验(issue #4 设计决策 B)。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["model", "messages", "stream", "stream_options"])
|
||||||
|
def test_protected_keys_rejected(self, key):
|
||||||
|
"""保护键会击穿治理: 成本算错/口径失真/绕过看门狗与 usage 帧。"""
|
||||||
|
from polygateway.types import validate_request_overlay
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
validate_request_overlay({key: "x"}, origin="chat(overlay=...)")
|
||||||
|
assert key in str(exc.value)
|
||||||
|
assert "chat(overlay=...)" in str(exc.value) # 信息须能定位来源
|
||||||
|
|
||||||
|
def test_non_str_key_reports_key_problem(self):
|
||||||
|
"""非 str 键须报"键必须是 str",不能被 sort_keys 的比较错误误报成不可序列化。"""
|
||||||
|
from polygateway.types import validate_request_overlay
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="str"):
|
||||||
|
validate_request_overlay({1: "a", "b": 2}, origin="test")
|
||||||
|
|
||||||
|
def test_unserializable_value_becomes_value_error(self):
|
||||||
|
"""裸 TypeError 会逃出 CacheMW 的降级 try 且一行遥测都没有(设计决策 B)。"""
|
||||||
|
from polygateway.types import validate_request_overlay
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="JSON"):
|
||||||
|
validate_request_overlay({"temperature": object()}, origin="test")
|
||||||
|
|
||||||
|
def test_returns_independent_copy(self):
|
||||||
|
"""调用方逐次改 seed 复用同一 dict 是预期模式,不拷贝会有竞态(决策 E)。"""
|
||||||
|
from polygateway.types import validate_request_overlay
|
||||||
|
|
||||||
|
caller_dict = {"temperature": 0, "seed": 42}
|
||||||
|
validated = validate_request_overlay(caller_dict, origin="test")
|
||||||
|
caller_dict["seed"] = 43
|
||||||
|
assert validated == {"temperature": 0, "seed": 42}
|
||||||
|
|
||||||
|
def test_merge_prefers_call_level(self):
|
||||||
|
"""优先级: 调用级 > 配置级(设计决策 A)。"""
|
||||||
|
from polygateway.types import merge_sampling
|
||||||
|
|
||||||
|
merged = merge_sampling({"temperature": 0, "top_p": 1}, {"temperature": 1})
|
||||||
|
assert merged == {"temperature": 1, "top_p": 1}
|
||||||
|
|
||||||
|
def test_canonical_json_is_key_order_stable(self):
|
||||||
|
"""缓存 key 与遥测列共用同一序列化口径,键序不得影响结果。"""
|
||||||
|
from polygateway.types import canonical_sampling_json
|
||||||
|
|
||||||
|
assert canonical_sampling_json({"b": 1, "a": 2}) == canonical_sampling_json(
|
||||||
|
{"a": 2, "b": 1}
|
||||||
|
)
|
||||||
|
assert canonical_sampling_json({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceConfigExtraBody:
|
||||||
|
"""配置级采样参数(issue #4 设计决策 A/E)。"""
|
||||||
|
|
||||||
|
def test_defaults_to_empty_and_is_read_only(self):
|
||||||
|
source = _make_source()
|
||||||
|
assert source.extra_body == {}
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
source.extra_body["temperature"] = 0 # MappingProxyType 只读
|
||||||
|
|
||||||
|
def test_protected_key_rejected_at_construction(self):
|
||||||
|
"""装配期报错,不放到运行时才炸(CLAUDE.md §4.5)。"""
|
||||||
|
with pytest.raises(ValueError, match="model"):
|
||||||
|
_make_source(extra_body={"model": "sneaky"})
|
||||||
|
|
||||||
|
def test_accepts_sampling_params(self):
|
||||||
|
source = _make_source(extra_body={"temperature": 0})
|
||||||
|
assert source.extra_body["temperature"] == 0
|
||||||
|
|
||||||
|
def test_replace_rebuilds_proxy(self):
|
||||||
|
"""决策 G 的剥离依赖 replace 能重跑 __post_init__ 且不递归。"""
|
||||||
|
source = _make_source(extra_body={"temperature": 0})
|
||||||
|
stripped = dataclasses.replace(source, extra_body={})
|
||||||
|
assert stripped.extra_body == {}
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
stripped.extra_body["x"] = 1
|
||||||
|
|
||||||
|
def test_no_longer_hashable_is_intentional(self):
|
||||||
|
"""加 mapping 字段的固有代价(裸 dict 亦然),库内无调用点会踩。
|
||||||
|
|
||||||
|
锁定为有意行为: 将来踩到的人不应把它当 bug"修"回去——要可变副本用
|
||||||
|
dict(source.extra_body),要改字段用 dataclasses.replace(设计 Task 1)。
|
||||||
|
"""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
hash(_make_source())
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""机械校验 Gitea Wiki 与源码的可比对事实(签名/导出/字段序/列清单/env 键)。
|
||||||
|
|
||||||
|
**只查机械可比对的部分**——机制语义、行为口径这类需要读懂代码才能判断的断言
|
||||||
|
不在此列(那部分靠 `解释-治理行为` 的适用性总表做单一事实源 + 人工审查)。
|
||||||
|
|
||||||
|
设计动机: 2026-08 对 wiki 做了七轮人工审查,88 条发现里有相当一部分属于
|
||||||
|
"机械可校验却写错"——`gather_bounded(coros, limit)`(实为 keyword-only 的
|
||||||
|
`concurrency`)、`LLMResponse` 字段表把 `source_name` 排进前 11 位、遥测列数
|
||||||
|
写成 20/21(实为 22 列)、`__version__` 在自称"全集"的页面缺席。这类偏差不该
|
||||||
|
靠人一轮轮追,故收敛为脚本。
|
||||||
|
|
||||||
|
用法(wiki 是独立仓库,须显式给路径;**不做 skip 静默降级**):
|
||||||
|
python3 tools/check_wiki_alignment.py --wiki /path/to/PolyGateway.wiki
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import polygateway
|
||||||
|
from polygateway import EmbeddingClient, GatewayClient, LLMResponse
|
||||||
|
from polygateway.config import _SOURCE_FIELDS
|
||||||
|
from polygateway.ocr import OcrClient
|
||||||
|
from polygateway.providers import register_provider
|
||||||
|
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
|
||||||
|
|
||||||
|
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
||||||
|
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
||||||
|
|
||||||
|
# (符号, 可调用对象) —— 这些的签名必须在 wiki 里逐参数出现
|
||||||
|
_SIGNATURE_TARGETS = [
|
||||||
|
("GatewayClient.chat", GatewayClient.chat),
|
||||||
|
("GatewayClient.from_env", GatewayClient.from_env),
|
||||||
|
("EmbeddingClient.from_env", EmbeddingClient.from_env),
|
||||||
|
("EmbeddingClient.embed", EmbeddingClient.embed),
|
||||||
|
("OcrClient.from_env", OcrClient.from_env),
|
||||||
|
("OcrClient.recognize_text", OcrClient.recognize_text),
|
||||||
|
("gather_bounded", polygateway.gather_bounded),
|
||||||
|
("register_provider", register_provider),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wiki_text(wiki: Path) -> dict[str, str]:
|
||||||
|
"""读全部 .md;文件名(不含后缀)→ 正文。"""
|
||||||
|
pages = {p.stem: p.read_text(encoding="utf-8") for p in sorted(wiki.glob("*.md"))}
|
||||||
|
if not pages:
|
||||||
|
raise SystemExit(f"错误: {wiki} 下没有 .md 文件,路径是否指向 wiki 克隆?")
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def check_exports_documented(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""`__all__` 每一项都得在某页出现过(R1 漏 gather_bounded、R6 漏 __version__)。"""
|
||||||
|
blob = "\n".join(pages.values())
|
||||||
|
missing = [name for name in polygateway.__all__ if name not in blob]
|
||||||
|
return [f"__all__ 的 {name!r} 在全部 wiki 页面中零命中(页首自称『顶层导出全集』)"
|
||||||
|
for name in missing]
|
||||||
|
|
||||||
|
|
||||||
|
def check_signatures(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""提到某个公共可调用的那一行,必须列全它的参数名。
|
||||||
|
|
||||||
|
只查「参数名是否出现」,不查顺序与类型——后者用自然语言表述合法。
|
||||||
|
历史命中: gather_bounded 的 concurrency 被写成 limit;EmbeddingClient/
|
||||||
|
OcrClient 的 from_env 用省略号承接 chat 的关键字集合,掩盖了没有 cache=。
|
||||||
|
"""
|
||||||
|
problems = []
|
||||||
|
for label, func in _SIGNATURE_TARGETS:
|
||||||
|
symbol = label.split(".")[-1]
|
||||||
|
params = [
|
||||||
|
p.name
|
||||||
|
for p in inspect.signature(func).parameters.values()
|
||||||
|
if p.name not in ("self", "cls")
|
||||||
|
]
|
||||||
|
# 找出提到该符号的所有行,任一行列全即算通过
|
||||||
|
lines = [
|
||||||
|
line
|
||||||
|
for text in pages.values()
|
||||||
|
for line in text.splitlines()
|
||||||
|
if f"`{symbol}`" in line or f"{symbol}(" in line
|
||||||
|
]
|
||||||
|
if not lines:
|
||||||
|
problems.append(f"{label}: wiki 里找不到任何提及")
|
||||||
|
continue
|
||||||
|
best_missing: list[str] | None = None
|
||||||
|
for line in lines:
|
||||||
|
missing = [
|
||||||
|
p for p in params
|
||||||
|
if p not in line and not (_PARAM_ALIASES.get(p, set()) & set(line.split()))
|
||||||
|
]
|
||||||
|
if not missing:
|
||||||
|
best_missing = []
|
||||||
|
break
|
||||||
|
if best_missing is None or len(missing) < len(best_missing):
|
||||||
|
best_missing = missing
|
||||||
|
if best_missing:
|
||||||
|
problems.append(
|
||||||
|
f"{label}: 没有任何一行列全参数,最接近的一行仍缺 {best_missing}"
|
||||||
|
f"(实际签名 {inspect.signature(func)})"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_llmresponse_field_order(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""字段表出现顺序须与 dataclass 声明顺序一致。
|
||||||
|
|
||||||
|
R2 命中: wiki 把 source_name 与 model/provider 并成一行(位置 5),而它实为
|
||||||
|
第 12 个字段。迁移中的三项目按位置构造 fake,照 wiki 写会静默错位。
|
||||||
|
"""
|
||||||
|
page = pages.get("参考-公共API")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 参考-公共API.md"]
|
||||||
|
# 只在 LLMResponse 小节内找: 别的类型(EmbeddingResponse 等)也有同名字段,
|
||||||
|
# 全页搜索会命中它们、把顺序判断带偏
|
||||||
|
start = page.find("## LLMResponse")
|
||||||
|
if start < 0:
|
||||||
|
return ["参考-公共API.md 缺少 `## LLMResponse` 小节"]
|
||||||
|
end = page.find("\n## ", start + 1)
|
||||||
|
section = page[start : end if end > 0 else len(page)]
|
||||||
|
declared = [f.name for f in dataclasses.fields(LLMResponse)]
|
||||||
|
positions = []
|
||||||
|
for name in declared:
|
||||||
|
# 取该字段在小节内最早的出现位置(表格首列可能写成 `a / b` 合并形式)
|
||||||
|
cands = [
|
||||||
|
section.find(pat)
|
||||||
|
for pat in (f"| {name} ", f"{name} /", f"/ {name} ", f"| {name}\n")
|
||||||
|
]
|
||||||
|
hits = [i for i in cands if i >= 0]
|
||||||
|
positions.append((name, min(hits) if hits else -1))
|
||||||
|
documented = [n for n, i in positions if i >= 0]
|
||||||
|
missing = [n for n, i in positions if i < 0]
|
||||||
|
problems = [f"LLMResponse 字段 {missing} 未在 参考-公共API 的字段表出现"] if missing else []
|
||||||
|
ordered = sorted((i, n) for n, i in positions if i >= 0)
|
||||||
|
actual = [n for _, n in ordered]
|
||||||
|
expected = [n for n in declared if n in documented]
|
||||||
|
if actual != expected:
|
||||||
|
problems.append(
|
||||||
|
f"LLMResponse 字段表顺序与声明顺序不符\n"
|
||||||
|
f" wiki 顺序: {actual}\n"
|
||||||
|
f" 声明顺序: {expected}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_telemetry_columns(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""遥测列清单与 sqlite 后端的 _COLUMNS 对齐(created_at 由 DDL 生成,单列)。"""
|
||||||
|
page = pages.get("指南-遥测与成本")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 指南-遥测与成本.md"]
|
||||||
|
expected = [*TELEMETRY_COLUMNS, "created_at"]
|
||||||
|
missing = [c for c in expected if c not in page]
|
||||||
|
problems = [f"遥测列 {missing} 未在 指南-遥测与成本 出现"] if missing else []
|
||||||
|
# 列数声明: 表 = _COLUMNS + created_at;端口 = _COLUMNS。
|
||||||
|
# 页内**所有** "N 列" 声明都必须等于真实列数——只查"正确值是否出现"会被
|
||||||
|
# 漏改的旧数字骗过(它们同时存在时检查照样通过)
|
||||||
|
table_n, port_n = len(expected), len(TELEMETRY_COLUMNS)
|
||||||
|
declared_counts = {int(m) for m in re.findall(r"(\d+)\s*列", page)}
|
||||||
|
if not declared_counts:
|
||||||
|
problems.append(f"指南-遥测与成本 未声明表列数(应为 {table_n} 列)")
|
||||||
|
elif declared_counts != {table_n}:
|
||||||
|
wrong = sorted(declared_counts - {table_n})
|
||||||
|
problems.append(
|
||||||
|
f"指南-遥测与成本 的列数声明 {wrong} 与实际 {table_n} 列不符"
|
||||||
|
f"(端口是 {port_n} 参数,两者差 created_at)"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_source_env_fields(pages: dict[str, str]) -> list[str]:
|
||||||
|
"""`_SOURCE_FIELDS` 的每个 FIELD 段都得在 参考-配置键 出现(R1 命中 EXTRA_BODY)。"""
|
||||||
|
page = pages.get("参考-配置键")
|
||||||
|
if page is None:
|
||||||
|
return ["缺少 参考-配置键.md"]
|
||||||
|
# 用词边界匹配: `f in page` 会让 EXTRA_BODY 被 EXTRA_BODYY 蒙混过关
|
||||||
|
missing = [f for f in _SOURCE_FIELDS if not re.search(rf"\b{re.escape(f)}\b", page)]
|
||||||
|
return [f"源键 FIELD 段 {missing} 未在 参考-配置键 文档化"] if missing else []
|
||||||
|
|
||||||
|
|
||||||
|
_CHECKS = [
|
||||||
|
("顶层导出覆盖", check_exports_documented),
|
||||||
|
("公共签名参数", check_signatures),
|
||||||
|
("LLMResponse 字段序", check_llmresponse_field_order),
|
||||||
|
("遥测列清单", check_telemetry_columns),
|
||||||
|
("源 env 键覆盖", check_source_env_fields),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--wiki", required=True, type=Path, help="PolyGateway.wiki 克隆目录")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.wiki.is_dir():
|
||||||
|
raise SystemExit(f"错误: {args.wiki} 不是目录")
|
||||||
|
|
||||||
|
pages = _wiki_text(args.wiki)
|
||||||
|
failed = 0
|
||||||
|
for label, check in _CHECKS:
|
||||||
|
problems = check(pages)
|
||||||
|
if problems:
|
||||||
|
failed += len(problems)
|
||||||
|
print(f"✗ {label}")
|
||||||
|
for p in problems:
|
||||||
|
print(f" {p}")
|
||||||
|
else:
|
||||||
|
print(f"✓ {label}")
|
||||||
|
print()
|
||||||
|
if failed:
|
||||||
|
print(f"{failed} 处机械偏差 —— wiki 与源码不一致")
|
||||||
|
return 1
|
||||||
|
print(f"{len(pages)} 页机械校验通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user