• v1.3.3 a716f12483

    iomgaa released this 2026-09-06 00:43:43 +08:00 | 0 commits to main since this release

    推理从「开 / 关」升级为档位(issue #20)。enable_thinking: bool | None 表达不了新一代模型:GLM-5.3 官方强制推理、只接受 low/high/max,none 不是它的档位——二态布尔在它上面无档可填,下游只能手写 extra_body,而那条路会静默绕过本库为推理准备的三道机制。本版把档位做成一等公民:八档封闭词汇、源级与请求级两个入口、能力表按档位登记、缓存 key 与遥测各加一维。

    版号是 patch(2026-09-05 人类指令,不因破坏性变更走 minor),但本版含五处破坏性变更与四条行为变更。 patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故全部置于最前。

    请先读这一条(一):五处破坏性变更

    # 位置 变更 谁会当场断
    1 ThinkingCapability 构造签名 can_disable: boolsupported_efforts: tuple[Effort, ...] 自建能力表的调用方(关键字与位置两种构造都断)
    2 ports.Transport.complete() 新增无默认值参数 reasoning_effort 任何自建 transport 实现
    3 ports.TelemetryRecorder.record_llm_call() 新增无默认值参数 reasoning_effort(25 → 26 参) 任何自建 recorder 实现
    4 thinking.resolve_thinking() 第三参数由 bool 换成 Effort,返回类型由 Mapping 改为 ThinkingResolution 直调它的读侧代码一律断
    5 providers.ProviderProfile 两个字段 thinking_on / thinking_off → 单字段 thinking: ThinkingWire 自建 profile 的调用方

    第 1 条的 can_disable 保留为只读派生属性(Effort.NONE in supported_efforts),只读它的代码一行不用改;构造则两种写法都断:

    1.3.2 的写法 升级后
    ThinkingCapability(can_disable=True, evidence="…")(库自己那张表用的就是它) TypeError: ... got an unexpected keyword argument 'can_disable'
    ThinkingCapability(True, "…") TypeError: 'bool' object is not iterable——断在 __post_init__ 的去重校验里,错误信息看不出真实原因
    迁移写法 ThinkingCapability(supported_efforts=(Effort.NONE, Effort.AUTO), evidence="…")

    第 2、3 条按这两个端口的既有纪律不设默认值:库外没有第三方实现者,带默认值只会让漏传时静默落一个默认值。第 4 条的新返回值是 ThinkingResolution(payload, applied_effort)——原来那个 mapping 现在是 .payload,多出来的 .applied_effort 是开了 nearest 映射后真正发出去的那一档。

    请先读这一条(二):不改一行代码也会变的四条行为

    # 变更 影响
    1 glm-5.3 / glm-5.3-flash / gemini-3.1-pro 首次进入能力表,且三者都登记为关不掉推理 本版唯一会打断存量配置的一条。 1.3.2 里这三个型号未登记,给它们配 ENABLE_THINKING=false 会按 provider 形态尽力注入并放行(只发一条 warning);本版在装配期ThinkingUnsupportedError。并排实测:deepseek/glm-5.3 + ENABLE_THINKING=false 在 1.3.2 返回 {"thinking": {"type": "disabled"}},在本版当场报错
    2 openai 段的开启方向由「形态未知即装配期报错」放宽为 on_base={} 把任意兼容厂商挂在 openai 段下并配 ENABLE_THINKING=true 的下游:1.3.2 在装配期报错,本版放行且一个字节都不注入——走模型自己的默认档。若该模型默认不推理,这个配置既不报错也不开推理(见下方「已知限制」)
    3 openai 段的关闭方向由「形态未知即装配期报错」放宽为 {"reasoning_effort": "none"} 同上但配 ENABLE_THINKING=false 的下游:1.3.2 在装配期报错,本版下发这个片段。放宽的依据是 reasoning_effort 是 OpenAI 官方字段而非厂商方言,经网关的兼容端点不会把它打到不认识它的厂商
    4 缓存 key 加入 reasoning_effort 只有新配 REASONING_EFFORT 的源冷启动一次;只配 ENABLE_THINKING 或什么都没配的源,key 字面量逐字不变(已按 1.3.2 的实现逐字比对)

    第 1 条是设计上有意为之:调用方要的是「不推理」的语义保证,给不了就必须说,而不是让它继续静默烧推理 token——升级后当场失败,正是这三个型号本来就关不掉推理的证据。报错文案带一条能立刻照做的替代(该模型最省的那一档 + 该配的 env 键名),不把人推回 extra_body 那条绕过库的路。

    qwen / deepseek / minimax 三段的注入形态逐字未变。 全量比对(4 个 1.3.2 已有的 provider 段 × 25 个模型 × ENABLE_THINKING 三态 = 300 种组合)显示,本版与 1.3.2 的差异只有上表第 1、2、3 条minimax 的「开」尤其值得点名:它维持 {"reasoning_effort": "medium"} 逐字不变,因为真实网关实测显示 MiniMax-M3 在不带任何推理参数时不推理(5/5 轮),把它改成「不注入即为开」会让存量 ENABLE_THINKING=true 的调用静默停止推理。

    新增能力

    新增 说明
    八档 Effort:none / auto / minimal / low / medium / high / xhigh / max 封闭词汇,取四家参考实现共同收敛的那一套。none = 要求不推理(与「不表态」是两回事),auto = 要求推理但不指定强度
    {SCOPE}__{PROVIDER}__{N}__REASONING_EFFORT 源级默认档。ENABLE_THINKING 保留,降为它的语法糖(trueautofalsenone、缺省 ≡ 不表态);两键语义矛盾(如 true + none)在装配期报错,不做「后者赢」的静默兜底
    {SCOPE}__{PROVIDER}__{N}__EFFORT_FALLBACK error(缺省,报错)或 nearest(映射到最近档并 warning)。默认报错的理由是钱:一次静默的 medium → max 在部分模型上是数倍账单
    chat(reasoning_effort=...) 请求级覆盖,优先级高于源级;裸字符串会在入口归一
    LLMResponse.applied_effort 本次实际跑在哪一档(开了 nearest 时与请求档分叉)。字段追加在末尾,既有字段只增不改名
    四个新 provider 段 zhipu / moonshot / anthropic / google 连同 1.3.2 已有的 qwen / deepseek / minimax / openai八段。四段都是新增,不改变任何存量配置的行为
    能力表由 5 条扩到 24 条 1.3.2 只登记 5 个型号,其余一律走「按 provider 形态尽力注入 + warning」。本版新登记 19 个:qwen 4 款、deepseek 2 款、GLM 6 款、kimi 2 款、gpt 2 款、claude 2 款、gemini 1 款
    kimi-k3 首次登记为可关闭 它在 1.3.2 未登记(配 false 走尽力注入 + warning,不报错)。本版实测坐实可关:请求 none 后短提示词 5/5 轮 + 长上下文 3/3 轮无任何推理信号、completion 恒 9 token,与同模型 max 档(rt 33-146)的锚点可分。两源分歧由此了结——OpenRouter 的 mandatory:false 是对的,官方档位表没列 none 只是没列
    包根新增导出 Effort / EFFORT_ORDER / ThinkingWire / ThinkingResolution 深路径 import 会被内部重组打断,一律从 polygateway 包根取

    档位不支持时报错必带可执行替代:模型关不掉推理时,错误文案直接给出该模型最省的那一档和该配的 env 键名。只报错不给出路,下游只会退回 extra_body——而那正是 issue #20 的成因。

    遥测:第 26 个 INSERT 字段 reasoning_effort

    llm_calls 新增一列 reasoning_effort TEXT(INSERT 字段 25 → 26,物理列 26 → 27)。列可空,NULL 表示调用方没表态;它与 'none'(明确要求不推理)是两回事,折叠成任一档都等于替上游声称了一件它没说过的事。加这一列是为了让「不同档位是不是真有用」这类压测在数据侧能分组——此前 25 列里没有任何一列能回答「这一行跑在哪档」。

    成功行与失败行不是同一把尺子。 开了 EFFORT_FALLBACK=nearest 的源上,成功行记的是映射后的实发档(读 response.applied_effort);失败尝试没有响应、实发档无从得知,记的是请求档。故 GROUP BY reasoning_effort 不带 error IS NULL 时,两种尺子会混进同一个分组。缓存命中行与终态失败行同样只记请求档——它们手上没有选中源,源级档位与 nearest 映射都无从谈起。embedding / OCR 两条路径没有推理语义,该列恒 NULL

    补列走既有的 PGW_TELEMETRY_SCHEMA_MODE,两端 DDL 与 COLUMNS 同源。manual 档的下游会看到一处文案变化:旧表的缺列告警会多点名 reasoning_effort 这个维度,并附上对应的 ALTER TABLE ADD COLUMN 语句。

    能力表口径:24 条里 17 条经 new-api 实测、7 条仍是文档推定

    DEFAULT_CAPABILITIES 共 24 条,每条 evidence 自报家门(实测日期、轮数 N、判据、锚点,或「文档推定」及其四方出处)。读能力表请以逐条 evidence 为准,本版不存在「能力表已全部实测」这回事。 未能实测的 7 条与原因:

    模型 未覆盖的原因
    claude-opus-5claude-sonnet-5 该渠道 claude 全系返回 429「api key 7 天限额已用完」,5/5 轮失败;none 档还额外依赖网关把 reasoning_effort=none 转成 thinking 关闭形态,同样未经验证
    gemini-3.1-pro 该渠道本型号上游报错(bad_response_status_code / openai_error),5/5 轮失败,连默认档基线都没取到。默认档「官方文档说 high、OpenRouter 说 medium」两源打架仍未决,本版不选边
    gpt-5.4 全账号限流(429 All available accounts are currently rate-limited),5/5 轮失败。同代的 gpt-5.5 已实测且与清单逐字相符,可作旁证但不是本型号的证据
    glm-5glm-5.1glm-5.2 请求这三个型号时,渠道 5/5 轮把流量路由到 glm-5.3(issue #20 记录的 6/6 复现);拿到的行为不属于本型号,整组数据作废

    glm-5.2 的下游风险要单独说:在这条渠道上给它配 none,库会照文档推定放行,而真正服务请求的 glm-5.3 关不掉推理;运行期对账会喊,但那是事后。

    另有两条与实测相关的收获值得下游知道:同一批实测发现 zhipu / moonshot 这条渠道不校验档位值(未登记的 medium 也照单收下并返回 200),故「网关没报错」在这两家上不构成「该档受支持」的证据;而 openai 那条会校验(清单外的 max / minimal 被上游 400 拒)。

    已知限制:auto 不等于「强制开推理」(issue #21)

    reasoning_effort=auto(含它的语法糖 ENABLE_THINKING=true)在 on_base={} 的三个 provider 段(openai / anthropic / google)上表达的是「用模型自己的默认档」,库不注入任何字节。若某模型默认就不推理,这个配置既不报错也不开推理。正解是让 auto 受能力表约束——模型不支持「由模型自定」时报错并指路显式档位,属公共行为变更,留到下一版(gitea issue #21)。

    与之相连有一处刻意的不一致,请勿误读:DEFAULT_CAPABILITIESMiniMax-M3supported_efforts 不含 auto(实测结论——它的默认档不推理),而 minimax 段的 wire 会为 auto 注入 {"reasoning_effort":"medium"} 并被放行。resolve_thinking 的 Phase 5 对 auto 无条件放行(auto 不是写进 effort_key 的取值,而是「不写 effort_key」),能力表拦不住这条路;当前是由 wire 侧的权宜之计兜住的。别把它读成「能力表能挡住 auto」。

    Downloads
  • v1.3.2 6ec9ec7056

    1.3.2 Stable

    iomgaa released this 2026-08-28 18:17:15 +08:00 | 37 commits to main since this release

    1.3.2(2026-08-28)

    本版不改库代码。 tools/tests/ 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 除版本号外没有任何差异(__version__ 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 tools/telemetry_retention.py 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。

    运维脚本:--table 让删除目标不再由连接环境决定(issue #18)

    tools/telemetry_retention.py 此前删哪张表,取决于连接的 search_path——它的首项是 "$user",所以换个角色跑同一条命令,目标可能就换了一张表。脚本会把解析到的限定名打出来,但那行打印与 DELETE 在同一次运行里,中间没有人。

    新增可选参数 --table <schema>.llm_calls:给了它,目标由参数精确解析(to_regclass 走引号限定名),绕开 search_path

    情形 行为
    不给 --table 与 1.3.1 完全一致,现有 cron 不受影响;但 --apply 时会多打印一行,提示目标是推断来的
    表名段不是 llm_calls 退出 1。本脚本只清理遥测表,不是通用清理器——一次 --table audit.events 的手误,会对一张恰好也有 created_at / tenant_id 的业务表跑同一套分批 DELETE
    显式指定的表不存在/不可见 退出 2,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因)
    显式指定的是分区表 仍退出 3 让路给 DROP PARTITION,语义未变

    退出码契约未新增也未改动。建议 cron 一律带上 --table:那一行配置从此自己说明删的是哪张表。

    测试隔离:从"事后观测共享表"改成"权限上做不到"

    issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张三个迁移项目也在写的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。

    行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表没有任何授权,search_path 万一落空就是 permission denied 而不是"但愿有断言发现"。共享表 llm_calls 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。

    对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。

    Downloads
  • v1.3.1 2bff962e48

    iomgaa released this 2026-08-26 16:39:14 +08:00 | 47 commits to main since this release

    「这次调用到底推理没推理」从此是库的一等返回值(issue #16 + #17): LLMResponse.thinking_observation 三态如实作答,判不出来时说 unknown 而不是伪装成「没推理」,并与推理能力表持续对账。

    版号是 patch,但本版含三处会影响下游的变更——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。

    请先读这一条(一): polygateway.providers 的深路径 import 断了

    推理相关的六个符号providers.py 移进新模块 polygateway.thinkingfrom polygateway.providers import ... 引用其中任何一个,升级后当场 ImportError:

    providers 断掉的符号 改成(推荐)
    ThinkingCapabilityThinkingUnsupportedError from polygateway import ... from polygateway.thinking import ...
    get_capabilityregister_capabilityresolve_thinking from polygateway import ... from polygateway.thinking import ...
    DEFAULT_CAPABILITIES from polygateway.thinking import DEFAULT_CAPABILITIES

    前五个请改用包根 import: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 ThinkingObservation,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。DEFAULT_CAPABILITIES 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。

    providers.py 保留的 ProviderProfile / DEFAULT_PROFILES / get_provider / register_provider 逐字未动。

    拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。

    请先读这一条(二): TelemetryRecorder.record_llm_call 从 24 参变 25 参

    新增 keyword-only 参数 thinking_observation: str,且按该 Protocol 的既有纪律不设默认值(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。自定义 recorder 实现必须同步补这个参数,否则调用时 TypeError。库自带的 SQLiteRecorder / PostgresRecorder 已同步,不受影响。

    TelemetryRecorder 之外的端口逐字未变;TelemetryStatusProvider 不受影响。

    请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来

    2026-08-25 实测: M3 非流式开启推理时 completion_tokens 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 reasoning_content 正文、也没有 usage.completion_tokens_details——钱花了,东西一个字都拿不到。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 unknown,并按 (模型, 方向)一次 warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。

    要拿到推理正文,该模型请走流式路径(实测 185 字符正文完整)。

    诊断纠正: 不是模型不推理,是 MiniMax 停报 completion_tokens_details

    issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 httpx 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,prompt_tokens 194→216、completion_tokens 3→60,三个独立信号一致。

    真正变的是 MiniMax 这一路上游不再返回 usage.completion_tokens_details(qwen 与 deepseek 在同一网关、同一 key 上照常返回),reasoning_tokens 因此恒为 None。而库把「推理是否发生」全押在这一个字段上,于是手里握着 185 字符推理正文,却对外报告「没推理」

    缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 None

    三态,以及它为什么不能折叠成布尔

    LLMResponse.thinking_observation(类型 ThinkingObservation,StrEnum,缺省 unknown)由多信号裁定,判据按证据硬度排序:

    判据
    observed 推理正文 thinking 非空(事实本身),或 reasoning_tokens > 0(上游对事实的转述)
    absent reasoning_tokens == 0——上游明确上报本次未推理,是正面证据
    unknown 两个信号双缺,判不出来

    unknownabsent 不是一回事,把前者折叠进后者正是本次故障的病根。unknown 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 unknown 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。

    对下游的口径变化: 统计「未推理」不要再写 reasoning_tokens IS NULL OR = 0,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 thinking_observation 分组,unknown 独立成一档。

    声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警

    推理能力表(can_disable)是静态声明,而静态声明必然过期——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。

    本版在 transport 拿到结果处做一次比较,矛盾即 warning(不抛错——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):

    请求方向 观测 告警内容
    关闭 observed 关闭请求未被满足。能力表已登记则点出 evidence 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入
    开启 absent 已注入开启参数,上游却明确上报未推理
    开启 unknown 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传

    关闭 × unknown 与「调用方没提要求」两类有意不表态: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 (源, 模型, 方向) 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。

    保障的覆盖面必须说清楚: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 observed 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。

    遥测新增一列 thinking_observation

    llm_calls 加一列 thinking_observation TEXT(可空,取值 observed / absent / unknown),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 INSERT 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。

    照 README「生产部署 DDL 模板」部署的下游不需要改模板: 那份模板用 LIKE llm_calls_seed 从库自己建出的表派生列,与 telemetry/schema.py 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。

    其他

    • 缓存回放的 thinking_observationThinkingObservation 枚举实例而非裸字符串: JSON 复活出来的是 str,与字段注解分叉,CacheMW._rehydrate 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)降级为 unknown 并单独告警,响应内容照常复活——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
    • M3 的推理能力 evidence 刷新到 2026-08-25 复测。can_disable 仍为 True(reasoning_effort=none → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;enable_thinkingthinking={"type":"enabled"} 对该模型无效,只有 reasoning_effort 是真开关。
    • TransportResult 同步新增该字段并由 RetryMW 透传;裁定在 openai_compat 的流式与非流式两条组装路径各做一次。
    • 遥测的新列只经 TelemetryEmitter._record 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 str——StrEnum 虽是 str 子类,asyncpg 的参数编码对 str 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: LLMResponse 无运行时校验,下游填裸 str 完全自然,而直接取 .value 会抛异常并被降级路径吞成丢掉整行遥测;域外取值同样只降级记 unknown 并单独告警,不拿整行当代价。
    Downloads
  • v1.3.0 f5cf69a1ac

    1.3.0 Stable

    iomgaa released this 2026-08-25 01:21:42 +08:00 | 69 commits to main since this release

    遥测后端从此按需占用连接、失败可自愈、降级可查询(issue #15)。提交方在一个 max_connections=100 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是人工比对"日志里的完成里程碑条数 vs llm_calls 行数"才发现的。

    根因不是"asyncpg 的默认 min_size=10 太大"这一条,而是四层叠加,只改默认值会留下三层:

    # 缺陷 本版
    库对自己的资源占用从未表态 —— create_pool(dsn, timeout=10) 继承第三方默认值,而 asyncpg 的 min_size 语义是"预连接"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 min_size=0 + max_size 可配(PGW_TELEMETRY_PG_POOL_MAX,缺省 4)+ 每次写入硬预算(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S,缺省 5.0s)
    判死判据挂在"哪一步失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 too many clients(下一秒可能就好) 判据改挂"失败是什么性质",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试
    降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 进入/恢复各一条日志 + 降级期间节流复述 + client.telemetry_status 只读快照
    "多个 client 共享一个 recorder"这条正道是坏的(第一个 aclose() 就把共享的 recorder 弄死),所以下游只能退回"每个 client 各占一份" 全库统一"谁建的谁关"纪律,共享路径打通

    真实实验室 PG 上的连接数实测,一眼可见差别: 修复前建完 recorder 就是 10 条;修复后建完 recorder 0 条 → 一次写入后 1 条 → 20 行并发后 4 条(= pool_max)→ aclose() 后回到 0

    请先读这一条(一): 最低 Python 版本提到 3.12,3.11 的部署装不上

    requires-python>=3.11 改为 >=3.12。这是本版四条要点里唯一会让下游装不上的变更——仍在 3.11 上的部署执行 pip install 会被 pip 直接拒绝,不是运行时报错,是装不了。升级 Python 或钉住 polygateway<1.3 二选一。

    抬版本不是顺手做的: 本版的写入预算依赖 asyncio.timeout,而 3.11.0 / 3.11.1 的 uncancel 有已知缺陷,继续支持 3.11 就得退回 wait_for 并绕开那个缺陷。取舍是缩小支持面换掉一整块补丁代码。同批把三处泛型函数改成 PEP 695 语法(def f[T](...),该语法在 3.11 是 SyntaxError)。

    请先读这一条(二): 遥测的常驻连接数会从 10 × client 数 掉到 0,监控曲线会突变

    这是纯改善,但曲线会跳,不要误判为故障: 连接不再于装配期预占,而是第一次写入时才建、忙时最多 PGW_TELEMETRY_PG_POOL_MAX 条(缺省 4)、空闲超过回收期后归 0。代价是首次写入多付一次建连(实测 ≈390ms,相对一次秒级 LLM 调用可忽略),稳态写入无差异(实测 123ms)。

    pool_max 的调参口径请按实测折算,不要按 pool_max / RTT 估算——那会乐观一倍: 跨内网 RTT ≈ 123ms 的实验室 PG 上,pool_max=4 实测约 15.6 行/秒(50 行并发批耗时 3.2s),因为一次 INSERT 的实际往返比一次 SELECT 1 重。缺省 4 配缺省 5s 预算能吞下约 50 行的突发,余量约 1.5 倍;超预算的行被丢弃并计入 telemetry_status.dropped_rows——丢一条遥测好过拖垮业务调用。多个 client 共享同一个 recorder 时并发在这里汇聚,应相应放大。

    请先读这一条(三): aclose() 不再关闭注入进来的组件

    新纪律是谁建的谁关,注入的一律不碰: from_env() / from_settings() 自建的 transport / recorder / limiter / breaker / cache 照常被 aclose() 关掉;经构造函数注入进来的则一律不碰,由注入方自己关。RedisCache 同款(注入的 redis 客户端不再被误关)。

    这修正的是一次越权——共享同一个 recorder 的多个 client 里,第一个 aclose() 会把其他 client 还在用的 recorder 弄死。但若你的代码依赖了"注入之后由 client 代关",升级后会漏关,请自行补上关闭。同一批还修掉了反方向的泄漏: 自建的 redis limiter / breaker 客户端此前从来没有人关(aclose 压根不持有它们的引用),现在会被关。

    请先读这一条(四): 直接构造 GatewaySettings 的代码要补两个参数

    GatewaySettings 新增 telemetry_pg_pool_max: inttelemetry_pg_write_timeout_s: float 两个无默认值的必填字段。走 from_env() / from_settings() 的调用方不受影响(两个新键都是可选的,env 装配路给缺省 4 与 5.0);直接构造 GatewaySettings(...) 的代码——测试装配、配置改写脚本——升级后不补参数会当场 TypeError

    这不是疏忽而是既有纪律: 相邻的 telemetry_auto_migrate / telemetry_text_cap 同样无默认值,缺省规则只写在 _load_* 一处,不与字段签名漂移(P4 显式优于隐式)。写默认值在此也不可能——这两个字段后面还跟着四个无默认值字段,加了就是 TypeError: non-default argument follows default argumentdataclasses.replace(settings, ...) 一路不受影响。

    遥测失败的三分判据

    判据两句话:致命 = 失败原因完全在进程内部且不可变;行级 vs 环境级看"失败与这一行的数据有没有关系"

    覆盖 处置
    配置级致命 DSN 不可解析(ClientConfigurationError)、建池参数非法 永久 no-op + 一条 error(这是人配错了,不是 warning)
    环境级不可用 连接类 08 / 资源不足 53(含 53300 too many connections)/ 管理干预 57 / 认证 28 / 库不存在 3D,以及 42501 无权限、42P01 表不存在;网络类异常;超时类异常仅在准备期路径可达(写入期的超时先被 record_llm_callexcept TimeoutError 接住,按行级丢弃);表确定不存在且建不出来 冷却 60s 后自动重试一次,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启
    行级拒绝 其余数据与约束类错误(22/23 等),外加唯一具名例外 42703(缺列) 逐条 warning 丢弃,不降级

    42703 之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪 INSERT 继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。

    新增公共 API

    名字 内容
    GatewayClient.telemetry_status / EmbeddingClient.telemetry_status / OcrClient.telemetry_status TelemetryStatus | None 只读属性。None = 未启用遥测,或注入的 recorder 不提供状态
    polygateway.TelemetryStatus(顶层导出) frozen dataclass: degraded / fatal / reason / degraded_for_s / dropped_rows / retry_after_s。下游可据此对账或告警,不必再人工比对行数
    ports.TelemetryStatusProvider 新增的独立可选端口。TelemetryRecorder 逐字未变——它是 @runtime_checkable,往里加成员会让所有只实现 record_llm_call 的对象当场不再满足协议,下游的同款 isinstance 断言升级即断

    其他

    • 两个新配置键 PGW_TELEMETRY_PG_POOL_MAX(缺省 4,须 ≥ 1)与 PGW_TELEMETRY_PG_WRITE_TIMEOUT_S(缺省 5.0,须 > 0)。GatewaySettings 相应新增两个无默认值的必填字段,与相邻三个遥测键(telemetry_auto_migrate / telemetry_text_cap / telemetry_sqlite_path)完全一致——上面那两个"缺省"只存在于 env 装配路(_load_* 函数),直接构造 GatewaySettings 的调用点必须补这两个参数,见"请先读这一条(四)"。PostgresRecorderpool_max / write_timeout_s 是 keyword-only 必填参数(直接构造 recorder 的调用点需补,不传即 TypeError)。
    • PostgresRecorder.aclose() 现在是有界且终局的: 走 asyncio.wait_for + 超时 terminate()(Pool.close() 在 in-flight 连接未释放时会无限等,asyncpg 自己的文档就建议加 wait_for);关闭后写入短路且不再复活——此前关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入外部池的调用方以为自己管着全部连接、实际早已不是。
    • 降级日志的级别由是否致命决定: 配置级致命(DSN 写不对)发 ERROR——人配错了、本进程内不会自愈,运维必须看见;其余(后端挂了、权限被收、表被删)发 WARNING——外部状态,冷却到期会自己重试。级别只在 TelemetryStatusTracker 一处决定,两个 recorder 共用。
    • 对账请同时看 degradeddropped_rows: 写入因本地池饱和超出预算被丢时走的是行级丢弃,degraded 保持 False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按 degraded 配告警会完全看不见这一类丢行——而它恰是 PGW_TELEMETRY_PG_POOL_MAX 配小了的唯一信号。
    • SQLite 遥测初始化失败后终于有日志了。此前 sqlite.py 初始化失败直接 return,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版只做可见性,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
    • 写入路径不再用 async with pool.acquire(...)Pool.release() 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
    Downloads
  • v1.2.4 f31f7caf99

    1.2.4 Stable

    iomgaa released this 2026-08-20 16:01:11 +08:00 | 91 commits to main since this release

    熔断开路时,调用方第一次可以选择而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队({SCOPE}__QUOTA_FULL=wait|fail_fast,缺省 wait),熔断门拒绝时只有 fail-fast 一档且不可配——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 {SCOPE}__CIRCUIT_OPEN=fail_fast|wait 补上这一格,形状与 QUOTA_FULL 逐项对齐。

    缺省是 fail_fast,即今天的行为,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,MAX_ATTEMPTS=8 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 wait 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长——上限是 STALL_WINDOW_S(缺省 300 秒)。wait 并不豁免重试预算: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 MAX_ATTEMPTS,所以密钥失效(401/403)这类一击即熔的源通常更早以 reason=retry_exhausted 失败,而不是等满窗口后的 stalled;两者哪个先到取决于 MAX_ATTEMPTS 与冷却时长、STALL_WINDOW_S 的相对大小。库无法区分"密钥坏了"和"中转抖了",选 wait 就是声明"宁可等也不当场死"。

    请先读这一条: retry_after_s 在半开状态下的取值变了(缺省档同样生效)

    retry_after_s 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:距离确定可再试的时刻还有多久。健康与准入允许 → 0.0;开路 → 剩余冷却;半开(探针在途)→ 0.0,因为探针随时可能出结果,不存在确定的时刻——而 0 = 可立即重试 本就是这个字段的既有约定。

    变更点在半开:此前返回的是探针租约剩余。那是个死锁保护参数,派生自 max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5),与"这个源多久能恢复"没有任何因果关系。TIMEOUT_S=300 的部署里它是 600 秒,而冷却期只有 60 秒。照它延期重投的下游,等的是一个物理上无意义的数。

    更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 set_until 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——本进程此后仍然跳过这个健康的源将近 10 分钟。单源下每次调用照旧抛 CircuitOpenError;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。

    同批统一了两个后端在六个出口上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。

    其他

    • _pick_runnable/_on_no_runnable 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 middleware/admission.py::SourceAdmission 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 None 时跳过),permit 结算的 warning 文案由三种归一为一种。
    • GatewayUnavailableError 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于任务级重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
    Downloads
  • v1.2.3 296c765337

    1.2.3 Stable

    iomgaa released this 2026-08-20 10:39:58 +08:00 | 103 commits to main since this release

    遥测表 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)里没有一个把它作为默认行为。

    同一版里,issue #12 补上这条边界的另一半——删数据,并把它落成三样手段: 遥测正文的可配置上限、tools/ 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样没有一样改变缺省行为——不设 PGW_TELEMETRY_TEXT_CAP 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 DELETE/DROP 权限: 保留期是 tools/ 下的独立脚本,库不 import 它。

    请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的

    1.2.1 的 README 给的 RLS 模板把写侧也绑在了 app.tenant_id 这个 GUC 上:

    -- 1.2.1 的模板,有缺陷,勿用
    CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
      USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
      WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
    

    PostgresRecorder一个连接池给所有租户写遥测,源码里从不发 set_config('app.tenant_id', ...)——库既拿不到也不该猜租户上下文该怎么设。于是 WITH CHECK 里的 current_setting 恒为 NULL、等值比较恒不为真,库的每一条 INSERT 都被 policy 拒绝。而遥测的失败方向是静默降级,所以表现不是报错,是整张表零行——业务调用一切正常,不看日志根本发现不了。

    照抄过就请现在查这两条:

    查什么 中招的样子
    SELECT count(*) FROM llm_calls;,且必须用能绕过 RLS 的角色(superuser 或带 BYPASSRLS 属性的角色)——FORCE 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" 启用 RLS 之后一直是 0,或从某个时刻起不再增长
    应用日志里遥测写入的降级告警,前缀 Postgres 遥测写入失败(丢弃该行): 每次调用刷一条,附带的 PG 原话是 new row violates row-level security policy for table "llm_calls"

    本版的新模板把写侧改为 WITH CHECK (true),隔离交由读侧USING 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 tenant_id,可把写侧收紧成 WITH CHECK (tenant_id <> ''),代价是漏传 tenant_id 的调用点会丢遥测行(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。

    破坏性变更(五项)

    # 变更 影响与应对
    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
    GatewaySettings 再新增必填字段 telemetry_text_cap: int | None 同 ③,只影响直接构造这条路。None(不截断)是取值而不是默认值——字段本身没有默认值;<= 0__post_init__ 直接 ValueError,不会被当成"不截断"
    TelemetryEmitter 新增 keyword-only 必填参数 text_cap 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 TypeError。同样故意不给默认值: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 text_cap 全汇流到这里,而 GatewaySettings 那道只管 env 一条路

    新增

    • 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,且只在准备期发一次,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。

    issue #12 交付的三样手段列在下表——它们改变的是能做什么,不是默认做什么:

    手段 内容
    PGW_TELEMETRY_TEXT_CAP(可选正整数键) 遥测落库正文的字符上限;不设 = 不截断(缺省)。作用面正好四处: messages 里每条消息的字符串 content、多模态 content 数组中 type == "text" 的 part 的 text,以及 responsethinking 两列;超出部分头部保留、尾部换成 …(略 N 字)按每条文本切,而不是切整串 JSON——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。覆盖面到此为止: 调用方塞进 tool_calls.function.argumentsnamecontent 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留
    tools/telemetry_retention.py(独立运维脚本) created_at 清理过期行。默认 dry-run: 先打出将删行数、created_at 窗口与按 tenant_id 的分布,让运维先判断"要删的是不是我想删的",给了 --apply 才真动手。退出码是与调度器(cron/systemd)的契约: 0 正常(含 dry-run)、1 参数错误、2 连接/权限/目标表不可用(含缺 asyncpg——明确报错退出,绝不静默变成"删了 0 行")、3 目标是 PostgreSQL 分区表,此时脚本拒绝 DELETE,让路给 O(1) 的 DETACH + DROP PARTITION。请用维护角色跑,不要用应用账号(模板已对它 REVOKE UPDATE, DELETE)
    README 新增「生产部署 DDL 模板(PostgreSQL)」一节 三角色、created_at RANGE 分区与 pg_partman retention、REVOKE UPDATE, DELETE 加触发器兜底、RLS、库自己需要的最小权限、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 <!-- pg-template:* --> 锚点,由 tests/integration/test_postgres_telemetry.py 从 README 解析出来在真实 PG 上逐条执行——模板只有这一份,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物

    变更

    • 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,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
    • 遥测缺省不截断: 不设 PGW_TELEMETRY_TEXT_CAP 时落库正文与今天逐字节相同。digest_messages(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断只产出新对象、绝不就地修改——digest_messages 对非 list 的 content 是原样透传同一个 dict 对象,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 build_cache_key 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
    • embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是"取更严者"的关系;多模态 image_url 早已是 sha256 摘要,不受 cap 影响。

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

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

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

    同一条边界的另一半是删数据: 库不持有 DELETE/DROP 权限,保留期与访问控制以 README 模板加 tools/ 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 REVOKE UPDATE, DELETE ON llm_calls(按不可变审计表对待),那么过期清理就不可能再由应用角色的 DELETE 完成,只能是属主对 created_at RANGE 分区的 DETACH + DROP PARTITION(那是 DDL,同样不触发不可变性触发器)。分区在这里不可替代,不是性能偏好。

    升级提示

    • from_env() / from_settings() 装配的下游无需改代码;Postgres 下游升级后建议执行一次 python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
    • 直接构造 SQLiteRecorder / PostgresRecorder 或直接构造 GatewaySettings 的调用点必须补上新参数/新字段,否则 TypeError
    • 截断不需要任何升级动作: 不设 PGW_TELEMETRY_TEXT_CAP 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
    • 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
    • README 的安装 pin 由 >=1.2.1,<2 收紧为 >=1.2.3,<2。按旧 pin 装的下游不会被锁死(仍会拿到本版),但显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关,而包内那份 README 描述的正是它们。
    Downloads
  • v1.2.1 2af445cfc2

    iomgaa released this 2026-08-19 00:55:58 +08:00 | 129 commits to main since this release

    每次调用现在可以带上租户标识与任意调用方自定义维度,并逐条落进遥测表(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 逐项补齐。代码零变更。
    Downloads
  • v1.2.0 17dcff41c3

    iomgaa released this 2026-08-17 11:36:32 +08:00 | 148 commits to main since this release

    网关拒绝一次调用时,它说的话不再丢失(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 只警告不阻塞),也没有仓库链接。代码零变更,自本版生效。
    Downloads
  • v1.1.2 2be89c47d8

    iomgaa released this 2026-08-07 23:25:48 +08:00 | 165 commits to main since this release

    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,现在可以收回——表存在时库不再需要该权限。

    Downloads