Files
PolyGateway/CHANGELOG.md
T
iomgaa ba4a138692 docs: document the schema mode and the expand-contract promise
README 新增「遥测表 schema 与升级纪律」: 库对下游库只发探测/INSERT/建表
三类语句、PGW_TELEMETRY_SCHEMA_MODE 三态与两端不对称缺省的理由、
telemetry_schema_sql 用法,以及五条 Expand/Contract 承诺。CHANGELOG 未发布段
把三处破坏性变更放在最前。ARCHITECTURE 新增 D15 并在 §7.8/§9 记下 schema
单一事实源与无冲突目标写入。

新增集成用例把 telemetry_schema_sql("postgres") 的输出在空临时 schema 里执行
两遍: 断言物理列 == COLUMNS ∪ {created_at},且第二遍不报错(补列语句的
IF NOT EXISTS 幂等性)。去掉 IF NOT EXISTS 该用例即红。
2026-08-19 13:03:45 -04:00

45 KiB
Raw Blame History

Changelog

未发布

遥测表 llm_calls 的结构变更从此由下游掌控(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 ALTER TABLE ADD COLUMN,而补列没有任何开关——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:ALTER 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 await),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。

请先读这一条:三处破坏性变更

# 变更 影响与应对
Postgres 侧不再自动补列(缺省转为 manual 档) 库升级带来新列时,旧表不会被自动 ALTER:库改为发一条 warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 INSERT 继续写入——缺的那几列静默不落库,直到有人执行那几条 SQL。要恢复旧行为设 PGW_TELEMETRY_SCHEMA_MODE=auto。SQLite 侧缺省不变(仍 auto),理由见下
两个 recorder 新增 keyword-only 必填参数 auto_migrate SQLiteRecorder(db_path, *, auto_migrate)PostgresRecorder(dsn, *, pool=None, auto_migrate);直接构造 recorder 的调用点必须补这个参数,不传即 TypeError故意不给默认值:缺省规则只写在 config 一处,不与类签名漂移
GatewaySettings 新增必填字段 telemetry_auto_migrate: bool 只影响「构造函数全量注入」这条装配路(测试/高级用法);from_env() / from_settings() 的用户零改动。telemetry_backend="none" 时该字段在 __post_init__ 归一为 False

新增

  • PGW_TELEMETRY_SCHEMA_MODE(可选键,值域 auto / manual),三态:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则有意不对称——postgresmanual,sqliteauto。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 runs/*.db),没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
  • 公共函数 telemetry_schema_sql(backend) -> str(已进顶层 __all__):返回可直接粘进迁移文件的完整脚本——注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句。PG 变体带 ADD COLUMN IF NOT EXISTS,整段可重复执行;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 backendValueError
  • manual 档的缺列告警逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且只在准备期发一次,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。

变更

  • Postgres 的写入去掉了冲突目标:ON CONFLICT (call_id) DO NOTHINGON CONFLICT DO NOTHING。普通表上语义逐字等价(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 (call_id) 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 created_at 分区后主键变成 (call_id, created_at),该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 INSERT OR IGNORE 本就无目标,未动。
  • manual 档按现有列裁剪 INSERT。这不是可选增强而是关掉 ALTER 的前提:旧表缺列时若仍发全量 INSERT,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
  • schema 常量收敛为单一事实源 telemetry/schema.py(内部模块):列序、两端 DDL、两端补列语句、INSERT 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是正确性而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。

不变

  • manual 档仍然建表。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 to_regclass 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
  • auto 档行为与从前逐字相同,包括补列失败时不裁剪:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
  • 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。

库对下游数据库的承诺(Expand/Contract,本版成文)

以下五条此前已被实现满足,但从未写成承诺。本版起它们是承诺:新列只增不删不改名且一律追加在既有列之后;新列必可空或带非易失常量默认值(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);INSERT 永远显式写出列名;库从不 SELECT *、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的冲突处理不绑定具体约束

合起来它们保证:你可以自行给 llm_calls 加列、加索引、挂 RLS,乃至把它建成 PARTITION BY RANGE (created_at) 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,research-wiki/ 不在 sdist 内。

升级提示

  • from_env() / from_settings() 装配的下游无需改代码;Postgres 下游升级后建议执行一次 python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
  • 直接构造 SQLiteRecorder / PostgresRecorder 或直接构造 GatewaySettings 的调用点必须补上新参数/新字段,否则 TypeError

1.2.1(2026-08-18)

每次调用现在可以带上租户标识与任意调用方自定义维度,并逐条落进遥测表(issue #11)。llm_calls 存的是完整正文(digest_messages 只对多模态 image_url 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列没有任何租户维度——能区分来源的只有 session_id / parent_call_id 两个调用方自填、库内不校验的自由字符串。

不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。

新增

  • 四个公共方法各增两个 keyword-only 参数 tenant_idmeta,都带默认值 None,既有调用点零改动: GatewayClient.chat()EmbeddingClient.embed()OcrClient.recognize_text()OcrClient.parse_layout()。issue 只诉求前两条链路;OCR 经同一个 TelemetryEmitter同一张表,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
  • 遥测表 llm_calls 新增两列,排在既有 22 列末尾,两端类型按各自后端的原生能力取:
Postgres SQLite
tenant_id TEXT NOT NULL DEFAULT '' TEXT NOT NULL DEFAULT ''
meta JSONB NOT NULL DEFAULT '{}'::jsonb TEXT NOT NULL DEFAULT '{}'
  • 老表经现有 _BACKFILL 机制自动补列(先探测再 ALTER,失败只逐行降级),补列后老行的 tenant_id 读出是空串而非 NULL。这个区别是刻意的: PG 的 RLS USING 表达式返回 false 或 null 的行都不可见、且静默跳过不报错,所以 NULL 的 tenant_id 在任何 policy 下都不是"未归属",而是对所有人永久不可见的黑洞;哨兵空串则显式可查,COUNT(*) WHERE tenant_id = '' 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
  • TelemetryRecorder.record_llm_call 由 22 字段扩为 24(inspect.signature 实测),ChatRequest 同步新增两个带默认值的字段。metajson.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False) 序列化,空 dict 落 '{}' 而非 NULL。

校验规则(超限报错,不静默丢弃)

校验在四个公共入口收口、进洋葱之前抛裸 ValueError,四条链路共用同一份实现:

规则
tenant_id 长度 ≤ 128;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug
meta 键数 16
meta 必须匹配 [a-z0-9_.]{1,64};pg_ 前缀保留给库将来的内建维度(本版库自身不写任何该前缀的键)
meta str / int / float / bool,嵌套需调用方自行序列化;字符串值 ≤ 256 字符;float 必须有限,nan / inf 报错(它们不是合法 JSON,PG 的 JSONB 会拒收)

报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。超限一律报错,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。

不变

  • tenant_idmeta 都不进缓存 key。租户级的缓存隔离由既有的 cache_namespace 负责,重复进 key 只会让全部存量缓存冷启动;且 meta 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。
  • 既有 22 列的列名与列序、ON CONFLICT (call_id) DO NOTHING 幂等、单条写失败逐行丢弃的降级方向全部未动。错误面零变更,下游 except 写法不受影响。
  • 缓存命中行与终态失败行同样带维度,且读的是本次 request 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。

边界: 库只交付列,RLS 与索引由下游执行

库不会执行 ENABLE / FORCE ROW LEVEL SECURITY,也不会建任何索引。 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 CREATE POLICY(并建 (tenant_id, created_at) 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 tenant_id 等值谓词,它必然是前导列);不执行则 tenant_id 只是一个可查、可过滤的普通列,没有任何数据库层强制

不自动启用的首要理由是 default-deny: 启用 RLS 而无匹配 policy = 零行可写,且静默不报错。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;CREATE POLICY / ALTER TABLE 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。

RLS 模板与三个陷阱(表属主默认豁免 RLS 需 FORCE;租户上下文必须在显式事务内 set_config(..., true),asyncpg 默认 autocommit 下单发 SET LOCAL 会当场失效而 PG 只发 warning;只写 USING 不写 WITH CHECK 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,research-wiki/ 不在 sdist 内。

升级提示

  • 升级无需任何代码改动: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 {}
  • README 的安装 pin 由 >=1.2,<2 收紧为 >=1.2.1,<2。按 >=1.2,<2 装的下游不会被锁死(仍会拿到本版),但显式装 1.2.0 就没有租户维度
  • README 的配置参考表此前漏列了源级 MISSING_DONEEXTRA_BODY(正文别处却引用了后者)、{SCOPE}__QUOTA_FULL、embedding 专用键、PGW_CACHE_BACKENDmemory 档与三个可选 PGW_* 键,本版按 config.py_SOURCE_FIELDS_load_pgw 逐项补齐。代码零变更。

1.2.0(2026-08-16)

网关拒绝一次调用时,它说的话不再丢失(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。

根因是三条留存通道同时为空: _status_to_error 手上握着 body_text 却只用于 429 的类型细分,该模块没有任何 logger 调用,异常类也没有承载响应体的字段。而库的逐次遥测写的是 str(exc),即 message——所以只给异常加字段并不能让它进遥测表,必须两者都做。

新增

  • 四分类错误新增 body_text 字段(加在 PolyGatewayError 基类): 非 2xx 响应体的摘要。与 ResultInvalidError.raw_text 分工明确——前者是"对方拒绝的理由"(非 2xx),后者是"2xx 但内容不可解析时的模型输出"。scope 级错误(GatewayUnavailableError 一族)恒为空串: 它们没有单一响应体可言。
  • 同一份摘要同时进入异常 message,故 SQLite/Postgres 遥测的 error 列里直接可查,下游不必为此单独埋点。

行为变更

  • 非 2xx 的 message 末尾追加 | {响应体摘要},覆盖两个 transport 的全部分支: chat 的 400 / 401·403 / 4xx 兜底 / 5xx / 429 两支(含 insufficient_quota),以及 OCR 的全部分支。issue 只报告了 chat 的 400,但 401 会 force_open 整个源、OCR 侧 message 原本只有一个状态码,是同一个缺陷的其余分支。
  • 摘要口径: 先折叠空白(错误体常是缩进 JSON,原样拼进 message 会把一行日志炸成多行),再限长 2048 字符(对齐 Kubernetes client-go 同场景的 maxUnstructuredResponseTextBytes)。超长时保留头 1400 + 尾 600并记下省略字数——JSON 错误体的 code / request_id 收在尾部,头部硬切正好会切掉向网关方追查时唯一有用的那部分。
  • 遥测 error 列因此变长: 纯 ASCII 约 2KB/条,最坏(5xx 重试 3 次)一次调用约 6KB。

不变

  • 状态码 → 错误分类的映射逐条未动(ARCHITECTURE §6.2 表),retry_after_s 解析、429 免重试预算、insufficient_quota 细分全部保持——429 的类型判定仍解析未截断的原文,若改用摘要,超长 body 的配额耗尽会退化成普通限速、该源不再 force_open
  • 异常类型树、str(exc) 之外的字段、遥测 22 字段与列序、DDL 全部未变。错误面零变更,下游 except 写法不受影响。
  • 400 仍按确定性失败处理(不重试不换源)。但请注意: 经第三方中转部署时,中转自身抖动也会回 400,从状态码上与"你的输入有问题"分不开(下游实测: 同一份字节 sha256 一致、重发 15 次全部成功,失败那次 prompt_tokens=0 且耗时远低于任何成功调用)。库不改默认语义——直连供应商时重试只会白烧配额——但 body_text 现在给了下游自行区分的判据。

升级提示

README 的安装 pin 由 ==1.1.* 改为 >=1.2,<2仍按 ==1.1.* 安装的下游会静默停在 1.1.2,拿不到本次修复且没有任何报错,请同步改自己的依赖约束。

  • 打包元数据补齐: readme[project.urls]。1.1.2 及之前的包在 registry 页面上没有任何说明正文(缺 readme 时 twine 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。

1.1.2(2026-08-07)

Postgres 遥测撞上建表权限就整体判死的问题(issue #9)。最小权限部署会静默丢掉全部遥测: 应用账号有表级 INSERT、表也已存在,但没有 schema 的 CREATE 权限时,初始化的 CREATE TABLE IF NOT EXISTS 被拒 → recorder 永久 no-op,业务调用一切正常,只留一行 warning。下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用耗时/token/成本因此全部丢失,且事后无法补回。

根因是 PostgreSQL 对 schema 的 CREATE 权限检查早于 IF NOT EXISTS 的存在性判断(PG 16.14 实测: 同一连接 INSERT 通过、to_regclass 看得见表,该 DDL 照样被拒)——与 issue #3 修过的 ALTER TABLE 是同一类问题,当时只修了补列那一半。

行为变更

  • PG 侧建表前先 to_regclass 探测,表已存在就一条 DDL 都不发。探测不需要任何权限,且与 INSERT 走同一套 search_path 解析(比裸 DDL 更准: 裸 CREATE TABLE 落在首个可建的 schema,可能与写入命中的不是同一张表)。表不存在时才建,新建表列已齐全,顺带跳过补列。
  • "结构性失能"的判据收窄为「确定写不进去」,不再是「初始化时出过异常」。仅两种情形仍永久降级为 no-op: 建池失败(重试要在业务路径上内联吞掉连接超时)、表确定不存在且建不出来(后续 INSERT 必然全败)。探测失败、取连接失败改为只跳过本条并 warning,下次调用重新准备——初始化瞬间的一次抖动不再让整个进程失遥测。
  • 日志措辞随之细分: 建池失败 / 建表探测失败(跳过本条,下次重试) / 建表失败(表不存在,记录无处可落),原先一律是 初始化失败

不变

  • SQLite 侧一行未改。实测其对已存在的表在解析期就把 CREATE TABLE IF NOT EXISTS 短路掉(另一连接持 BEGIN EXCLUSIVE、文件 chmod 444 时该语句均通过,而同条件的 INSERT 分别报 database is locked / readonly database),没有同款风险;加探测零收益,故有意不对称,只在 docstring 钉死实测结论。
  • 遥测端口签名、22 字段、列序、ON CONFLICT DO NOTHING 幂等、单条写失败逐行丢弃的降级方向全部未动。错误面零变更

升级提示

若你的部署此前为了绕开本问题给应用账号授了 CREATE ON SCHEMA,现在可以收回——表存在时库不再需要该权限。

1.1.1(2026-08-06)

stall 判定改为非生产性等待口径(issue #8)。timeout_s ≥ stall_window_s 时,一次耗满超时的请求就会让整个 scope 被判死,配置的重试次数一次都用不上——而且没有任何报错或 warning,配置方以为自己配了 3 次重试。stall_window_s 默认 300 恰是个很容易被 TIMEOUT_S 追平的值,"只配 timeout、不配 stall"这种最常见的写法正好踩中。

根因是两个预算重叠计费: 真实尝试的耗时同时向重试预算(max_attempts)与 stall 预算(stall_window_s)计费,而后者更小,必然先耗尽。

行为变更(请先读这一条)

  • stall 判定的"本地超窗"条件现在只累计非生产性等待——429 退避、配额 wait 轮询、熔断冷却;消耗重试预算的真实尝试不再计入。两个预算自此正交,划分依据是谁消耗重试预算: 烧 max_attempts 的时间不烧 stall_window_s,不烧 max_attempts 的时间(含 429 尝试本身)归 stall_window_s 治理。
  • stall_window_stimeout_s 不再有任何耦合,无需按 timeout × retries 放大。若你此前为绕开本 bug 把 STALL_WINDOW_S 调大过,现在可以回到默认值。
  • 单次调用的最坏耗时由 stall_window_s 抬升到约 max_attempts × timeout_s(默认配置下 3 × TIMEOUT_S,再加各次退避)。这是重试预算恢复生效的正确表现,但如果你的上游有调用超时,请据此复核。429 路径同样不突破这个量级——429 虽免重试预算,但其尝试耗时计入 stall 账。 上述量级的前提是 stall 判死能够触发,即整个 scope 无进展(progress_age_s() > stall_window_s)。判死是双条件合取,这一条未变: 若同 scope 里其他调用仍在正常出餐,本调用会继续等待换源而不判死——这正是双条件的设计意图("别人还活着,不该因我一路不顺就宣告整个 scope 死亡")。代价是这种情形下调用级没有硬上限,持续遭遇慢 429 的调用可以等很久。该性质由条件 B 单独门控,早于本次修复即如此(旧口径实测同样无界),不是本次引入;但若你需要调用级硬上限,请在调用方用 asyncio.wait_for 自行设置。
  • 三条治理循环(chat / embedding / ocr)口径一致。embedding 与 ocr 此前有同一缺陷(经"先超时一次、再遇到无可用源"触发),issue 只记录了 chat 路径。
  • 遥测收尾属"真实尝试"边界之内,遥测抖动不会把一次调用推进 stalled 判决

不变

  • 双条件判死的结构、progress_age_s()inf 语义(从未出餐 = 全局超窗)、429 免预算、退避与 jitter 公式、fail_fast 分支、AllSourcesExhausted 的字段与 reason 取值(仍是 stalled)全部未动。错误面零变更,下游 except 写法不受影响。
  • 装配期校验 stall_window_s ≥ 最大源 ttft_timeout_s 保留。新口径下它已是保守冗余(TTFT 等待属生产性时间),但无害且不误拒合理配置。

1.1.0(2026-08-06)

治理后端故障归位为 scope 级不可用(issue #7)。限流/熔断的状态后端(Redis 等)自身故障时,库按降级方向铁律 fail-closed——整个 scope 一个请求都发不出去,语义上就是"scope 级暂时不可用"。但 GovernanceBackendError 此前是 PolyGatewayError 的直接子类,只写 except GatewayUnavailableError 的调用方接不住,后果很具体: Redis 抖一下,积压任务一批批消耗业务失败预算,够到上限就进死信——而那是运维重启一下就好的故障

行为变更(请先读这一条)

  • GovernanceBackendError 现在能被 except GatewayUnavailableError 捕获。 它改为继承该类,reason 恒为新增的 governance_backend_down下游对后端故障的处置路线因此改变: 从"落进兜底分支、按业务失败处置"变为"按 scope 级不可用延期重投、不消耗失败预算"。这正是本次修复的目标,但升级前请确认下游的兜底分支没有依赖旧行为(例如靠它触发告警)。既有的 except GovernanceBackendError 继续有效——加父类是扩大捕获面,不是破坏。
  • 配置写错(源名与限流后端配置不匹配)现在抛 SourceNotConfiguredError 而非 GovernanceBackendError 该类有意不在 GatewayUnavailableError 之下: 那是装配缺陷不是暂时故障,必须消耗失败预算、进死信、让人看见。若随整类归入可重投家族,配置写错的任务会永远重投且无人告警——恰是本次要修的 bug 的镜像。
  • GovernanceBackendError 的构造签名增加必填 keyword scope 库内 20 处构造点已全部更新;若下游有自行构造该异常的代码(罕见)需同步补 scope

新增

  • SourceNotConfiguredError(公共导出)。源名不在限流后端配置字典中时抛出,正常不可达,属装配缺陷。
  • GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0,GovernanceBackendError.retry_after_s 的默认值。不是环境配置项——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),故取保守固定值。不取 0: 那会让积压任务零延迟同时冲击已挂掉的后端,把一次故障放大成一场风暴。
  • scope 级 reason 值域增 governance_backend_down(由 5 值扩为 6 值)。
  • README 新增"哪些异常会到达调用方"两列表。四分类里 TransientError / SourceDeadError 被重试循环接住、耗尽时包成 AllSourcesExhausted,根本到不了调用方,而这只看类型树与 docstring 读不出来——曾让下游据此写错整段设计文档。

下游请读

  • GovernanceBackendError 现携带 scope / reason / retry_after_s,与 AllSourcesExhausted 同款(per_source_reasons 属性存在但恒为 {}——后端故障不针对具体某个源);str(exc) 仍是原来的诊断串(如 限流后端 try_acquire 失败: ...),结构化字段与诊断信息并存,排障不受影响。
  • 五条闸门路径的后端故障会到达调用方: QuotaGatetry_acquire / stats / progress_age_s,BreakerGatetry_enter / retry_after_s。记账路径(record_success / record_failure / release_probe / mark_progress)仍被 _record_quietly 降级为 warning,这个分工不变。
  • CHSAnalyzer 迁移: tracking.py 一条 except GatewayUnavailableError 即覆盖完整,无需为后端故障单列分支(migrations/chsanalyzer.md G1 已补注)。

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: noneTrue 注入 medium。此前依赖"设了 false 但其实没关"这一实际行为的调用方,行为会变。
  • MiniMax-M2.7 / MiniMax-M2.5ENABLE_THINKING=false 会在装配期报错。 这两个模型的推理关不掉,是模型固有属性(三种参数形态各 15 轮实测全部无效,OpenRouter 与 models.dev 两个外部注册表独立登记为强制推理)。调用方要的是"不推理"的语义保证,给不了就必须说,而不是装出一个骗人的 client。
  • provider=openai 的源配任何非 NoneENABLE_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_tokensNone 是"本次调用未上报",不是"该源不上报",与 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_thinkingopenai / minimax 两个 provider 不产生任何效果(它们的 thinking profile 两档皆空)。此前没有任何地方说明这一点,调用方可能以为自己关掉了推理。需要下发自定义参数请用 extra_body

1.0.4(2026-07-31)

响应可观测字段扩展(issue #3)。下游 dissect 要把每次调用落成一行审计记录,其中两列拿不到值:供应商侧 prompt cache 命中了多少 token、这次调用实际跑的是哪个模型版本。前者关系到能否把「缓存命中率差异带来的成本」与「实验条件本身带来的成本」分开,后者关系到实验快照的可复现性。本次把两者暴露到公共类型与遥测表,并让成本换算认识缓存单价。

新增(纯增字段,不破坏任何现有调用方)

  • LLMResponse 新增 cached_prompt_tokens: int | Nonemodel_reported: str | None 前者是供应商 prompt cache 命中的输入 token 数(OpenAI 兼容格式的 usage.prompt_tokens_details.cached_tokens),后者是 API 响应体里的 model 字段(与 .env 配的别名可能分叉——供应商把别名指向新权重时,只有它认得出真正跑的那个版本)。两者均带默认值 None,逐字段传参的 fake 构造零改动。
  • None0 是两回事,不可混同。 None = 该源不上报这个数(下游据此声明「本源不可做缓存成本校正」);0 = 该源上报了一次真实零命中。网关报文一律不可信:形态异常(负数、字符串、boolprompt_tokens_details 非 dict)一律归 None 且绝不抛异常——可观测字段缺失不得打断调用。
  • 遥测表 llm_calls 新增 cached_prompt_tokensmodel_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 缓存命中行的这两个字段是原样回放的历史值(与 modelprompt_tokens 同一口径:CacheMW 只覆写与本次调用相关的时序字段),计入会重复计数。这与 1.0.3 里 cost 缺口口径的坑是同一类。
  • 缓存命中行的 cost 仍恒为 0.0(未产生新调用),该短路排在任何单价换算之前,不受缓存单价档影响。
  • 旧格式的缓存条目(缺这两个键)照常可重建为 None,不会回源;历史遥测行的新列为 NULL。

1.0.3(2026-07-30)

est_tokens 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。

行为收紧/变更(下游请读)

  • usage_source 新增第三个值 unavailable 值域由 measured/estimated 两态变三态:unavailable 表示用量信息不可得(usage 帧缺失、失败尝试、终态失败),estimated 收窄为"有实测数字但可信度降级"(只剩打捞路径这一个生产者:收到 usage 帧但流被截断)。历史库里既有的 estimated 行语义不变、读兼容;按 usage_source 分支的下游代码需要认识新值。OCR 成功行不受影响,仍是 measured(0 token 是事实而非未知)。
  • 用量不可得的行,cost 由数值变 NULL。 此前 usage 帧缺失时库拿 est_tokens(按定义是最坏情形上界)当实测值,又整块塞进 completion_tokens 换算——输出单价通常是输入的数倍,实测双重高估约 26 倍;est_tokens=0 时则算出 0.0,让"免费"与"未知"在数据上不可区分。现在这类行如实记 0/0 + unavailable + cost=NULLSUM(cost) 天然跳过 NULL,账目缺口用 WHERE usage_source = 'unavailable' AND cache_hit = false 量化(cache_hit 限定不可省:缓存命中行未产生新调用,cost 仍是事实上的 0.0,本无缺口)。成本汇总若此前依赖"cost 非空"的隐含假设,请复核。
  • est_tokens 由必填降为可选调优覆盖。 装配校验 tpm > 0 ⇒ est_tokens > 0 已删除——它把供应商配额(运维能从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。未填时库按 max(1, tpm // 60) 派生("一次调用约占一秒钟的配额份额",尺度无关:任何配额规模都收敛到约 60 个在途)。字段与 {SCOPE}__{PROVIDER}__{N}__EST_TOKENS 环境键保留不删不改名,显式填值仍然优先。此前为绕开该校验而把 tpm 限死为 0 的调用方,现可填真实 TPM。

1.0.2(2026-07-30)

1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 from_env 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。

修复

  • 后端选择与条件必填项在任何构造路径上都校验。 以下此前只有 from_env 拦得住,from_settings() 与直接构造一律放行:limiter_backend/breaker_backend/cache_backend/telemetry_backend/selector/quota_full 六个字段的合法域;取 redis 的后端必须有 redis_url;启用缓存必须有 cache_namespace 与正 cache_ttl_s;telemetry_backendsqlite/postgres 时对应的路径/DSN 必填;structured_max_retries 非负;scope 非空。
  • client.py 五处断言的前提现在真的成立。 assert settings.redis_url is not None # 内部不变量: config 已校验 之类的注释此前在 from_settings 路上是假的:断言开启时抛不含任何字段信息的 AssertionError,python -O 下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。
  • 构造路补齐了 from_env 一直在做的规范化,两条装配路对同一输入产出同一个值:
    • scope 小写并去空白。它直接进 Redis key(pgw:limit:{scope}:…pgw:gate:{scope}:…),此前一个进程走 from_env("LLM") 拿到 llm、另一个直接构造传 "LLM",同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间,各记各的配额与熔断状态,分布式治理静默失效且不报错。
    • redis_urlpricing_path 的空串归 None。留着空串会骗过 is None 判断,把错误推迟成 redis 客户端的连接串解析异常或 Is a directory: '.'
    • Postgres DSN 剥掉 SQLAlchemy 驱动后缀(postgresql+asyncpg://…+asyncpg asyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经 from_env 装配的不受影响也不会有这条 warning(_load_pg_dsn 早就剥干净了)。
  • EmbeddingSettingsbatch_size / expected_dim 域校验也移入构造期,此前只有 EmbeddingSettings.from_env 校验,直接构造出 batch_size=-3 要到 EmbeddingClient 构造时才 fail-loud。

行为收紧(下游请读)

同 1.0.1:经 from_env() 装配的调用方不受影响。手工构造 GatewaySettings 或对它 dataclasses.replace 的调用方,若配置组合非法,现在会在构造期抛 ValueError 并点出字段名,而不是留到运行时表现为静默不建后端、裸 AssertionError 或第三方库的天书报错。

一处静默改值需要留意:此前手工构造传 scope="LLM"(非全小写)的调用方,升级后 scope 会被规范化为 llm,Redis key 随之从 pgw:limit:LLM:… 切到 pgw:limit:llm:…。这正是本次要修的问题——旧行为下这批 key 与 from_env 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。

1.0.1(2026-07-30)

修复

  • 装配守卫在任何构造路径上都生效,不再只在 from_env 上。 三条跨字段不变量(源 timeout_slease_ttl_sstall_window_s ≥ 最大源 TTFT、probe_ttl_s ≥ 最慢源 timeout_s + 5)原先只在 GatewaySettings.from_env 里校验,而装配有两条官方路——走 from_settings() 或直接构造能装出违反不变量的配置且不报错,故障留到运行时才表现为:租约先于请求过期使并发悄悄超出配额、正常慢首包被误判卡死掐断、半开探针在途即被接管。守卫已收进 GatewaySettings.__post_init__,与 types.py 各子配置一致,三个 client(Gateway/Ocr/Embedding)的全部工厂一并覆盖。
  • 新增 sources 非空校验。此前零源配置只在 from_env 路径被拦,直接构造可装出必然选源失败的 client。

行为收紧(下游请读)

直接构造 GatewaySettings 或对它做 dataclasses.replace 时,若上述组合非法,现在会在构造期抛 ValueError,而不是留到运行时。经 from_env() 装配的调用方不受影响——那条路本就跑这些守卫。手工拼配置(如从 YAML 读出后构造)的调用方若此前撞上过上述任一故障,升级后会在启动时立即得到点名字段的报错。

守卫报错文案的补救建议改为点字段名(lease_ttl_sbackpressure.stall_window_sbreaker.probe_ttl_s)。原文案已点出字段名,但建议部分给的是环境变量键(如"调大 PGW_LEASE_TTL_S"),而不走 env 的调用方从没设过那些键。键名映射见 .env.example 与 wiki 参考-配置键

1.0.0(2026-07-22)

首个正式版。统一 LLM/VLM/OCR/Embedding 调度与中转库,治理单位为一次模型调用;经 GovDoc-SaaS 与 CHSAnalyzer 两个真实项目全量迁移验收(ARCHITECTURE §11)。

  • M1 核心: types/errors/ports 内核、OpenAI 兼容 httpx transport(SSE + 非流式)、三层活性看门狗、自研重试(错误四分类驱动,换源/退避/Retry-After)、多源多账号 + 选源 + 源冷却、内存限流/熔断、Redis/内存响应缓存(key 含 namespace/salt/多模态摘要)、SQLite 遥测(18 字段必录)、结构化输出阶梯(json_repair/原生 schema + 有界重问)、provider 注册表、from_env 装配。
  • M2 分布式: Redis 六道闸限流(Lua,契约测试双后端共用)、跨进程熔断(单探针租约 + epoch fencing)、背压 stall 双条件判定、Postgres 遥测、pricing 成本、EmbeddingClient(分批/维度校验)。
  • M2.5 治理韧性: 双通道熔断(失败率窗 + 连败 + 健康证据抑制)、健康感知选源(EWMA×在途 P2C 缺省)、AIMD 自适应并发、429 免重试预算、健康门槛降权;故障混编 soak 同场景 58.1%→98.96%。
  • M3 OCR: OcrTextPort/OcrLayoutPort 端口族 + MonkeyOCR 双端点 transport(数值防御下沉)、OcrClient 独立治理循环、check_health() 逐源预检;OCR soak 1500 调用 99.73%。
  • M4 迁移验证: GovDoc 与 CHS 全量迁移(合计约 −6800 行项目治理代码由库继任),原测试全绿 + 真实冒烟 + 50 样本回归;Gitea PyPI 分发。

安装(实验室 Gitea PyPI):

pip install --index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    --extra-index-url https://pypi.org/simple/ "polygateway[redis,postgres,structured]==1.0.*"