DSH / Atlas
2026-08-18implementedfeature

pi-ai Wire-Compatibility Surface in llm-pi-ai

pi-ai Wire-Compatibility Surface in llm-pi-ai

pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether `store` and `stream_options` go out, whether tool definitions carry `strict`. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": `detectCompat` returns `supportsDeveloperRole: true`, `maxTokensField: "max_completion_tokens"`, `supportsStore: tru

English

Problem

pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether store and stream_options go out, whether tool definitions carry strict. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": detectCompat returns supportsDeveloperRole: true, maxTokensField: "max_completion_tokens", supportsStore: true. A hand-declared route is by construction an endpoint pi-ai does not ship, so every such route received OpenAI's own request shape.

The adapter offered two of pi-ai's thirty compat fields ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] scoped them to "the switches pi-ai's reasoning dispatch reads"), and supportsDeveloperRole fell inside that scope while being absent from it: its send site is model.reasoning && compat.supportsDeveloperRole. A hand-declared model declaring reasoningEfforts therefore sent its system prompt as role: "developer", which most OpenAI-compatible gateways reject, and no configuration could say otherwise — the gateway could not be connected at all.

Writing the field anyway was worse than unsupported. schemastery passes unknown keys through, and resolution read only two names, so compat: {supportsDeveloperRole: false} validated, persisted, and was then dropped: the operator saw an accepted write and an unchanged failure. maxTokensField carried the same defect over a wider blast radius, since it shapes every request rather than only a reasoning model's.

Decision

One drift gate per pi-ai compat type — keyed Record<keyof OpenAICompletionsCompat | …, CompatDisposition> — classifies every upstream field as offer or withhold. Thirty distinct fields, twenty offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for openRouterRouting or deferredToolsMode is a catalog route that should be named as such and inherit the value.

PiAiCompatProfile stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what docs/config-catalog.md pastes — and a type-level AssertNever over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared z<PiAiCompatProfile>, and exactOptionalPropertyTypes is what makes that annotation load-bearing in both directions, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation. Field types are derived from upstream rather than restated, and a second proof pins the profile assignable to the upstream compat types, so a widened value union cannot silently narrow what configuration accepts — the cast to ModelCompat at materialization would otherwise hide it.

Protocol applicability is per field, and grouping follows the compat type rather than the protocol name: pi-ai gives openai-responses, azure-openai-responses, and openai-codex-responses one OpenAIResponsesCompat, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from Model.compat's own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. chatTemplateKwargs is offered, which is what makes the two chat-template thinking formats nameable; nothing cross-checks that pairing, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read.

Three kinds of compat key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's ?? reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as {} and chatTemplateKwargs is present on every parsed profile whether or not anyone wrote one.

Where a refusal lands

Every check runs in resolveProfiles, which no request path re-enters: the adapter memoizes by raw-snapshot identity and apply resolves once eagerly. A refusal therefore reaches settings.mutate as settings-rejected before persistence, a cordis.yml config: block as a failed plugin mount, and a stored section as a failed settings.register at startup.

An external edit to the settings file is the one path that cannot report: the provider watcher calls publish(), which catches a failing section, logs settings: keeping last good "%s", and leaves the namespace serving its previous value. That is the settings seam's behavior for every schema and validator failure, not something this surface introduces, and closing it belongs to that seam rather than here. What changes for compat is the failure model, not the reporting: a key that formerly stayed inert forever now stops the next start.

Alternatives considered

Add supportsDeveloperRole alone. It fixes the reported gateway and leaves maxTokensField — which shapes every request, not only a reasoning model's — breaking a whole class of endpoints, with the next upstream addition free to lag silently again.

Offer every upstream field. pi-ai's own custom-provider documentation converges on a far smaller set, its flagship example naming six, and the remainder are vendor-bound switches its catalog already sets. Exposing zaiToolStream or vercelGatewayRouting on a hand-declared route offers a knob whose correct use is to not be a hand-declared route.

Key compat by protocol (compat: {openai-completions: {…}}). A hand-declared route has exactly one api, so the nesting states what the route already said, and it breaks every profile written against the flat shape for nothing.

Accept an opaque passthrough dict. The schema is also the shape a configuration surface renders and the declaration verify-config-catalog cross-checks, both of which an unstructured dict defeats; it would also let a responses-only field land on a completions model, which per-field applicability exists to refuse.

Warn instead of refusing an unknown key. That is the posture that hid this defect for the life of the surface: an accepted write and an unchanged failure teaches the operator that the switch does not work, not that the name is wrong.

Suggest a near spelling on an unknown key. No repository utility computes edit distance, and adding a dependency or hand-rolling one under the per-file coverage gate is disproportionate for a diagnostic. Naming the offered fields answers the same question deterministically: the vocabulary check runs before any protocol resolves, so it names the whole offered set, while the per-protocol refusal narrows to what that protocol takes.

Consequences

  • An OpenAI-compatible gateway that rejects the developer role, max_completion_tokens, store, stream_options, or strict is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting temperature or tool cache_control.
  • A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how chatTemplateKwargs and the chat-template formats stopped being a standing exception.
  • Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write.
  • Deferred, not closed: a route that repoints api and configures no compat at all keeps the installed entry's compat through the model literal's ...base spread, in the other protocol's shape. Fields several compat types share (supportsLongCacheRetention, sendSessionAffinityHeaders) therefore cross protocols. It predates this surface — the early return it rides existed before — and is left for its own change.
  • Deferred, not closed: publish() reports a rejected stored section only through ctx.logger.warn, with no user-visible channel. It affects every settings namespace and is owned by dsh-settings.
  • [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its reasoningEfforts shape, the alternatives that shape beat, and modelOverrides remain the current authority.

中文

Problem

pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、是否发出 storestream_options、工具定义是否携带 strict。对于其检测无法识别的端点,答案是「这就是 OpenAI 本身」:detectCompat 返回 supportsDeveloperRole: truemaxTokensField: "max_completion_tokens"supportsStore: true。而手工声明的路由按其构造就是 pi-ai 未随附的端点,于是每一条这样的路由都收到了 OpenAI 自己的请求形状。

适配器只开放了 pi-ai 三十个 compat 字段中的两个([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 把它们限定为「pi-ai 推理分派读取的那些开关」),而 supportsDeveloperRole 恰恰落在该作用域之内却不在其中:它的发送点是 model.reasoning && compat.supportsDeveloperRole。因此一个声明了 reasoningEfforts 的手工声明模型会把系统提示词以 role: "developer" 发出——多数 OpenAI 兼容网关会拒绝该角色——而没有任何配置能够更正,该网关根本接不进来。

硬写这个字段比不支持更糟。schemastery 会放行未知键,而解析只读取两个名字,于是 compat: {supportsDeveloperRole: false} 通过校验、落盘,随后被丢弃:运维看到的是一次被接受的写入和一个毫无变化的故障。maxTokensField 带着同一缺陷、却有更大的波及面,因为它塑造每一个请求,而不只是推理模型的请求。

Decision

每个 pi-ai compat 类型一张漂移门禁——以 Record<keyof OpenAICompletionsCompat | …, CompatDisposition> 为键——把每一个上游字段分类为 offerwithhold。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 openRouterRoutingdeferredToolsMode 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。

PiAiCompatProfile 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 docs/config-catalog.md 所粘贴的东西——并由一个作用在对称差上的类型级 AssertNever 证明它恰好命名了开放集。schemastery schema 声明为 z<PiAiCompatProfile>,而使这条标注在两个方向上都真正吃劲的是 exactOptionalPropertyTypes,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的类型派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 ModelCompat 的强转会把它洗掉。

协议适用性逐字段判断,且归组依据是 compat 类型而非协议名:pi-ai 让 openai-responsesazure-openai-responsesopenai-codex-responses 共用同一个 OpenAIResponsesCompat,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 Model.compat 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。chatTemplateKwargs 予以开放,这正是两个 chat-template 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。

三类 compat 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 ?? 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 {},于是无论有没有人写过,chatTemplateKwargs 都出现在每一个解析过的 profile 上。

Where a refusal lands

所有检查都在 resolveProfiles 中运行,而请求路径不会重新进入它:适配器按原始快照的标识 memoize,且 apply 会主动预先解析一次。因此一次拒绝会以 settings-rejected 的形式在落盘之前抵达 settings.mutate,以插件挂载失败的形式抵达 cordis.ymlconfig: 块,以 settings.register 启动失败的形式抵达已存的 section。

对 settings 文件的外部编辑是唯一无法报告的路径:提供方监听器调用 publish(),它捕获失败的 section、记录 settings: keeping last good "%s",并让该 namespace 继续服务其先前的值。这是 settings seam 对每一种 schema 与校验器失败的既有行为,并非本次开放引入,弥合它属于那个 seam 而不属于此处。对 compat 而言改变的是失败模型而非报告方式:一个从前永远静默无效的键,如今会拦下下一次启动。

Alternatives considered

只补 supportsDeveloperRole 它修好了报告中的那个网关,却放任 maxTokensField——它塑造每一个请求,而不只是推理模型的请求——继续拖垮一整类端点,而且下一个上游新增字段依然可以静默落后。

开放全部上游字段。 pi-ai 自己的 custom-provider 文档收敛到一个小得多的集合,其旗舰示例只点名六个,其余都是其 catalog 已经设定好的厂商绑定开关。在手工声明路由上暴露 zaiToolStreamvercelGatewayRouting,等于提供一个「正确用法是别做手工声明路由」的旋钮。

compat 按协议分层compat: {openai-completions: {…}})。手工声明路由恰好只有一个 api,因此这层嵌套只是复述路由已经说过的事,还白白破坏了所有按扁平形状写下的 profile。

接受一个不透明的透传 dict。 该 schema 同时是配置界面渲染的形状、也是 verify-config-catalog 交叉校验的声明,无结构的 dict 会同时击溃两者;它还会让 responses 独有的字段落到 completions 模型上,而逐字段适用性正是为拒绝这种情况而存在。

未知键只告警不拒绝。 这恰恰是让本缺陷伴随该面存活至今的姿态:一次被接受的写入加一个毫无变化的故障,教给运维的是「这个开关没用」,而不是「这个名字写错了」。

为未知键给出近似拼写建议。 仓库中没有计算编辑距离的工具,在逐文件覆盖率门禁之下为一条诊断引入依赖或手搓一个都不成比例。点名开放字段能确定地回答同一个问题:词汇检查跑在任何协议解析之前,因此它列出整个开放集,而按协议的拒绝则收窄到该协议实际接受的字段。

Consequences

  • 拒绝 developer 角色、max_completion_tokensstorestream_optionsstrict 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 temperature 或工具 cache_control 的 Anthropic 兼容网关同理。
  • pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——chatTemplateKwargs 与那两个 chat-template 格式正是因此不再是一项长期例外。
  • 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。
  • 搁置而非解决: 改指 api 且完全未配置 compat 的路由,会经模型字面量的 ...base 展开保留已安装条目的 compat,且形状属于另一个协议。多个 compat 类型共有的字段(supportsLongCacheRetentionsendSessionAffinityHeaders)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。
  • 搁置而非解决: publish() 对被拒绝的已存 section 只通过 ctx.logger.warn 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 dsh-settings
  • [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 reasoningEfforts 形状、该形状所击败的备选方案以及 modelOverrides 仍是当前权威。