A sixteen-row matrix over 127 real calls: disable and enable on MiniMax-M3 in both streaming and non-streaming mode, extra_body winning over the profile slot, qwen and deepseek still disabling correctly, a drift sentinel that re-derives every registered capability from live behaviour, and the assembly guard refusing the models that cannot comply. Two judgement criteria had to be corrected by the data they were meant to judge. Output length cannot separate the two regimes at all -- the disabled runs reach 46 tokens when the model narrates its working in the visible answer, and the enabled runs drop to 13 when medium effort barely thinks. reasoning_tokens separates them cleanly in both directions, which is precisely what issue #6 was collected for. A second anchor compares prompt_tokens between the two regimes: the vendor injects a reasoning instruction when thinking is on, so the input side grows, and comparing the two runs relatively avoids hardcoding any vendor number. Provider names are mapped explicitly rather than guessed from the model string; guessing had silently skipped the qwen row behind a "source unavailable" reason that was not true.
20 KiB
Changelog
未发布(issue #5 + #6)
推理开关能力建模与 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)
est_tokens 解耦(issue #2):一个常量此前被派了两份对"保守"定义相反的差事——TPM 入场预扣(押多了只是慢,安全)与 usage 缺失时的用量兜底(按上界记账只会账单虚高)。本次把两者拆开。
行为收紧/变更(下游请读)
usage_source新增第三个值unavailable。 值域由measured/estimated两态变三态:unavailable表示用量信息不可得(usage 帧缺失、失败尝试、终态失败),estimated收窄为"有实测数字但可信度降级"(只剩打捞路径这一个生产者:收到 usage 帧但流被截断)。历史库里既有的estimated行语义不变、读兼容;按usage_source分支的下游代码需要认识新值。OCR 成功行不受影响,仍是measured(0 token 是事实而非未知)。- 用量不可得的行,
cost由数值变 NULL。 此前 usage 帧缺失时库拿est_tokens(按定义是最坏情形上界)当实测值,又整块塞进completion_tokens换算——输出单价通常是输入的数倍,实测双重高估约 26 倍;est_tokens=0时则算出0.0,让"免费"与"未知"在数据上不可区分。现在这类行如实记0/0+unavailable+cost=NULL。SUM(cost)天然跳过 NULL,账目缺口用WHERE usage_source = 'unavailable' AND cache_hit = false量化(cache_hit限定不可省:缓存命中行未产生新调用,cost 仍是事实上的0.0,本无缺口)。成本汇总若此前依赖"cost 非空"的隐含假设,请复核。 est_tokens由必填降为可选调优覆盖。 装配校验tpm > 0 ⇒ est_tokens > 0已删除——它把供应商配额(运维能从配额页抄到)与库的实现细节(预扣量,无人能正确取值)绑死。未填时库按max(1, tpm // 60)派生("一次调用约占一秒钟的配额份额",尺度无关:任何配额规模都收敛到约 60 个在途)。字段与{SCOPE}__{PROVIDER}__{N}__EST_TOKENS环境键保留不删不改名,显式填值仍然优先。此前为绕开该校验而把tpm限死为 0 的调用方,现可填真实 TPM。
1.0.2(2026-07-30)
1.0.1 的续作:那一版把三条跨字段守卫收进构造期后,独立验证发现 from_env 上还留着同一类的 15 条校验与 4 条规范化,一并收拢。
修复
- 后端选择与条件必填项在任何构造路径上都校验。 以下此前只有
from_env拦得住,from_settings()与直接构造一律放行:limiter_backend/breaker_backend/cache_backend/telemetry_backend/selector/quota_full六个字段的合法域;取redis的后端必须有redis_url;启用缓存必须有cache_namespace与正cache_ttl_s;telemetry_backend取sqlite/postgres时对应的路径/DSN 必填;structured_max_retries非负;scope非空。 client.py五处断言的前提现在真的成立。assert settings.redis_url is not None # 内部不变量: config 已校验之类的注释此前在from_settings路上是假的:断言开启时抛不含任何字段信息的AssertionError,python -O下断言被移除、错误退化为 redis 库抛出的连接串解析异常。注释已改为点明由哪个校验方法保证。- 构造路补齐了
from_env一直在做的规范化,两条装配路对同一输入产出同一个值:scope小写并去空白。它直接进 Redis key(pgw:limit:{scope}:…、pgw:gate:{scope}:…),此前一个进程走from_env("LLM")拿到llm、另一个直接构造传"LLM",同一逻辑 scope 的限流与熔断状态会分裂到两套命名空间,各记各的配额与熔断状态,分布式治理静默失效且不报错。redis_url、pricing_path的空串归None。留着空串会骗过is None判断,把错误推迟成 redis 客户端的连接串解析异常或Is a directory: '.'。- Postgres DSN 剥掉 SQLAlchemy 驱动后缀(
postgresql+asyncpg://…的+asyncpgasyncpg 不认)。这一条剥的时候会发一条 warning——库动了调用方给的值,不该静默;日志只出现 scheme 段,DSN 带密码,整串不进日志。经from_env装配的不受影响也不会有这条 warning(_load_pg_dsn早就剥干净了)。
EmbeddingSettings的batch_size/expected_dim域校验也移入构造期,此前只有EmbeddingSettings.from_env校验,直接构造出batch_size=-3要到EmbeddingClient构造时才 fail-loud。
行为收紧(下游请读)
同 1.0.1:经 from_env() 装配的调用方不受影响。手工构造 GatewaySettings 或对它 dataclasses.replace 的调用方,若配置组合非法,现在会在构造期抛 ValueError 并点出字段名,而不是留到运行时表现为静默不建后端、裸 AssertionError 或第三方库的天书报错。
一处静默改值需要留意:此前手工构造传 scope="LLM"(非全小写)的调用方,升级后 scope 会被规范化为 llm,Redis key 随之从 pgw:limit:LLM:… 切到 pgw:limit:llm:…。这正是本次要修的问题——旧行为下这批 key 与 from_env 装配的进程根本不在同一命名空间;但切换发生的那一刻,旧键上的在途租约会被遗弃,靠 TTL 自愈。滚动升级期间建议留意限流配额短暂偏松。
1.0.1(2026-07-30)
修复
- 装配守卫在任何构造路径上都生效,不再只在
from_env上。 三条跨字段不变量(源timeout_s≤lease_ttl_s、stall_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_s、backpressure.stall_window_s、breaker.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.*"