From 44bd9182ff17722b6f8583d341ad7a9ae037de9e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:19:01 +0800 Subject: [PATCH 1/3] chore(llm): bump pi-ai to 0.84.2 --- ...-08-18-pi-ai-wire-compat-surface.i18n.yaml | 4 +- .../2026-08-18-pi-ai-wire-compat-surface.md | 8 +- ...2026-08-18-pi-ai-wire-compat-surface.zh.md | 8 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 9 ++ docs/config-catalog.zh.md | 9 ++ packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/catalog.ts | 28 ++++-- packages/llm/llm-pi-ai/src/config.ts | 10 +- packages/llm/llm-pi-ai/src/stream.ts | 21 ++++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 8 +- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 45 +++++++-- packages/llm/llm-pi-ai/tests/config.spec.ts | 11 +++ packages/llm/llm-pi-ai/tests/convert.spec.ts | 8 ++ pnpm-lock.yaml | 93 +++++-------------- pnpm-workspace.yaml | 2 +- 20 files changed, 169 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml index 9a9f1614fd..8d482bf05e 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md -2026-08-18-pi-ai-wire-compat-surface.md: 3da2db1ebdf67bcfaf8c872491356b0ef7d0ca89 -2026-08-18-pi-ai-wire-compat-surface.zh.md: ff9870f5863fb96ee026dfab1b96b4e1f3e6e238 +2026-08-18-pi-ai-wire-compat-surface.md: 280ce2dd16bb7f89f238c8e0fab07ed74de943f1 +2026-08-18-pi-ai-wire-compat-surface.zh.md: 1a5704dc8c462a8007d1129de4ec7a51e9bd22a4 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md index 3da2db1ebd..280ce2dd16 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -14,13 +14,13 @@ Writing the field anyway was worse than unsupported. schemastery passes unknown ## Decision -One drift gate per pi-ai compat type — keyed `Record` — 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. +One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty-four distinct fields, twenty-three 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`, 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. +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` and `chatTemplateArgs` are offered, which makes the two `chat-template` formats and `baseten` nameable; nothing cross-checks those pairings, 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. +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 absent dicts as `{}` and both template-argument fields are present on every parsed profile whether or not anyone wrote them. ## Where a refusal lands @@ -45,7 +45,7 @@ An external edit to the settings file is the one path that cannot report: the pr ## 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. +- A pi-ai upgrade that adds a compat field or thinking format fails the build until someone classifies it; the gates include both template-argument fields and every current format. - 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`. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md index ff9870f586..1a5704dc8c 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -14,13 +14,13 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 ## Decision -每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 +每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十四个字段,开放二十三个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 `PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,而使这条标注在两个方向上都真正吃劲的是 `exactOptionalPropertyTypes`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的**类型**派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 `ModelCompat` 的强转会把它洗掉。 -协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 +协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 与 `chatTemplateArgs` 予以开放,因此两个 `chat-template` 格式和 `baseten` 都可命名;这些配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 -三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 +三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,两个模板参数字段都会出现在每一个解析过的 profile 上。 ## Where a refusal lands @@ -45,7 +45,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 ## Consequences - 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 -- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- pi-ai 升级新增 compat 字段或思考格式会使构建失败,直到有人为它做出分类;门禁包含两个模板参数字段和当前每一种格式。 - 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 - **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。 - **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 775907aed0..8ab6ab4460 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: f255e38fdbc3c5831a625510110bb8a52ea280ad -config-catalog.zh.md: f1f6774d957858e452f7120baeb78ef1712c6543 +config-catalog.md: 163608f410ea034a9adfe4f73a1015b4ab5cafc5 +config-catalog.zh.md: a4f48470a2f9f608a14d8447f42efe27863ef506 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f255e38fdb..163608f410 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1133,6 +1133,11 @@ export interface PiAiCompatProfile { supportsReasoningEffort?: boolean /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean + /** + * Whether streams include `finish_reason`; `false` lets pi-ai infer the + * terminal reason when the stream ends; `openai-completions`. + */ + supportsFinishReason?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ @@ -1153,6 +1158,10 @@ export interface PiAiCompatProfile { * can read, so kwargs set beside another format are sent nowhere. */ chatTemplateKwargs?: NonNullable + /** Arguments sent as `chat_template_args` under the `baseten` thinking format; `openai-completions`. */ + chatTemplateArgs?: NonNullable + /** Whether the endpoint accepts `thinking_token_budget` to cap vLLM reasoning; `openai-completions`. */ + supportsThinkingTokenBudget?: boolean /** * Whether the endpoint accepts `strict` in tool definitions; * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f1f6774d95..a4f48470a2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1135,6 +1135,11 @@ export interface PiAiCompatProfile { supportsReasoningEffort?: boolean /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean + /** + * Whether streams include `finish_reason`; `false` lets pi-ai infer the + * terminal reason when the stream ends; `openai-completions`. + */ + supportsFinishReason?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ @@ -1155,6 +1160,10 @@ export interface PiAiCompatProfile { * can read, so kwargs set beside another format are sent nowhere. */ chatTemplateKwargs?: NonNullable + /** Arguments sent as `chat_template_args` under the `baseten` thinking format; `openai-completions`. */ + chatTemplateArgs?: NonNullable + /** Whether the endpoint accepts `thinking_token_budget` to cap vLLM reasoning; `openai-completions`. */ + supportsThinkingTokenBudget?: boolean /** * Whether the endpoint accepts `strict` in tool definitions; * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 42364c1d5c..a7021eb1cd 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 43472de90803481deebb9bc91586a4b85e443db5 -README.zh.md: 76b3dc9dec2a4bf83933319aa065954a191e595b +README.md: 9ef6596490b614a3d4af3dd6b52eb9b9fe87a335 +README.zh.md: 10f366659a38f52f7700c0db7953b983fd0e623a diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 43472de908..9ef6596490 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -97,7 +97,7 @@ pi-ai shapes each request from the provider id and baseURL: which role carries t Each switch belongs to the protocols whose pi-ai compat type declares it, and grouping follows the compat *type* rather than the protocol name: the three Responses protocols (`openai-responses`, `azure-openai-responses`, `openai-codex-responses`) share one compat type, so a switch settable on one is settable on all three. `supportsDeveloperRole` is settable on `openai-completions` and on those three; `thinkingFormat` only on `openai-completions`; `supportsTemperature` only on `anthropic-messages`; `supportsStrictMode` also reaches `bedrock-converse-stream`. 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 at all. -Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. +Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsAdditionalTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 76b3dc9dec..10f366659a 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -97,7 +97,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 每个开关归属于其 pi-ai compat 类型声明了它的那些协议,且归组依据是 compat **类型**而非协议名:三个 Responses 协议(`openai-responses`、`azure-openai-responses`、`openai-codex-responses`)共用同一个 compat 类型,因此可设在其中之一的开关,三者皆可设。`supportsDeveloperRole` 可设在 `openai-completions` 与这三者上;`thinkingFormat` 只能设在 `openai-completions`;`supportsTemperature` 只能设在 `anthropic-messages`;`supportsStrictMode` 还可达 `bedrock-converse-stream`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。 -三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。 +三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsAdditionalTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 0fd59afaa1..d0755b06cc 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -43,7 +43,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@earendil-works/pi-ai": "^0.82.1", + "@earendil-works/pi-ai": "^0.84.2", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index f06b27a1ea..37d4ea48f6 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -374,7 +374,7 @@ export class PiAiAdapter extends LlmAdapter { // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) - const iterator = toStreamChunks(events, model.contextWindow)[Symbol.asyncIterator]() + const iterator = toStreamChunks(events, model.contextWindow, options.signal)[Symbol.asyncIterator]() let exhausted = false try { while (true) { diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 1e9b126dd5..62259c7264 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -89,9 +89,9 @@ export type PiAiThinkingFormat = NonNullable = { 'deepseek': true, 'openrouter': true, 'together': true, + 'baseten': true, 'zai': true, 'qwen': true, 'chat-template': true, @@ -217,6 +218,7 @@ const COMPLETIONS_COMPAT_GATE = { supportsDeveloperRole: 'offer', supportsReasoningEffort: 'offer', supportsUsageInStreaming: 'offer', + supportsFinishReason: 'offer', maxTokensField: 'offer', requiresToolResultName: 'offer', requiresAssistantAfterToolResult: 'offer', @@ -224,6 +226,8 @@ const COMPLETIONS_COMPAT_GATE = { requiresReasoningContentOnAssistantMessages: 'offer', thinkingFormat: 'offer', chatTemplateKwargs: 'offer', + chatTemplateArgs: 'offer', + supportsThinkingTokenBudget: 'offer', supportsStrictMode: 'offer', cacheControlFormat: 'offer', supportsLongCacheRetention: 'offer', @@ -243,6 +247,7 @@ const RESPONSES_COMPAT_GATE = { supportsLongCacheRetention: 'offer', sessionAffinityFormat: 'withhold', supportsOpenAIGrammarTools: 'withhold', + supportsAdditionalTools: 'withhold', supportsToolSearch: 'withhold', supportsExplicitPromptCacheMode: 'withhold', } as const satisfies Record @@ -344,6 +349,11 @@ export interface PiAiCompatProfile { supportsReasoningEffort?: boolean /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean + /** + * Whether streams include `finish_reason`; `false` lets pi-ai infer the + * terminal reason when the stream ends; `openai-completions`. + */ + supportsFinishReason?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ @@ -364,6 +374,10 @@ export interface PiAiCompatProfile { * can read, so kwargs set beside another format are sent nowhere. */ chatTemplateKwargs?: NonNullable + /** Arguments sent as `chat_template_args` under the `baseten` thinking format; `openai-completions`. */ + chatTemplateArgs?: NonNullable + /** Whether the endpoint accepts `thinking_token_budget` to cap vLLM reasoning; `openai-completions`. */ + supportsThinkingTokenBudget?: boolean /** * Whether the endpoint accepts `strict` in tool definitions; * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. @@ -432,10 +446,10 @@ export type EveryProfileFieldMatchesUpstream = AssertTrue< * * schemastery materializes an absent dict as `{}` — the behavior * `reasoningEfforts` works around with a union — so every parsed profile - * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty - * one states nothing here: it would send no kwargs, which is exactly what - * leaving the field out does, so absent and empty are the same request and - * neither may make a route look like it configured a switch. A valueless + * carries both template-argument keys whether or not anyone wrote them. An + * empty one states nothing here: it would send no arguments, which is exactly + * what leaving the field out does, so absent and empty are the same request + * and neither may make a route look like it configured a switch. A valueless * scalar is the other thing schemastery lets through, and it is refused by * {@link assertOfferedCompatFields} before this runs rather than filtered. * @param compat - the configured switches, when any. diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 5fdcc2cdb2..28e409e734 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -227,9 +227,10 @@ const thinkingBudgets = z.object({ }) /** - * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder - * for a value dispatch fills from the request's thinking state, which is what - * makes a chat-template gateway configurable without restating its template. + * One `chat_template_kwargs` or `chat_template_args` value. The `$var` member + * is pi-ai's placeholder for a value dispatch fills from the request's + * thinking state, which makes a template-driven gateway configurable without + * restating its template. */ const chatTemplateKwarg: z = z.union([ z.string(), @@ -247,6 +248,7 @@ const compatProfile: z = z.object({ supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), supportsUsageInStreaming: z.boolean(), + supportsFinishReason: z.boolean(), maxTokensField: z.union(MAX_TOKENS_FIELDS), requiresToolResultName: z.boolean(), requiresAssistantAfterToolResult: z.boolean(), @@ -254,6 +256,8 @@ const compatProfile: z = z.object({ requiresReasoningContentOnAssistantMessages: z.boolean(), thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), chatTemplateKwargs: z.dict(chatTemplateKwarg), + chatTemplateArgs: z.dict(chatTemplateKwarg), + supportsThinkingTokenBudget: z.boolean(), supportsStrictMode: z.boolean(), cacheControlFormat: z.union(CACHE_CONTROL_FORMATS), supportsLongCacheRetention: z.boolean(), diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index d45df45a39..72c93a4634 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -71,7 +71,8 @@ function classifyPiAiError(message: string): string { * @returns the mapped harness reason. Recognized error text, `stop` usage above * `contextWindow`, and zero-output `length` usage that fills the window map * to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an - * `EMPTY_RESPONSE` error. + * `EMPTY_RESPONSE` error, while terminal `pending` and `deferred` states map + * to non-retryable `PI_AI_ERROR` failures. */ export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { const piAiOverflow = isContextOverflow(message, contextWindow) @@ -104,6 +105,14 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } case 'toolUse': return { kind: 'tool-calls' } + case 'pending': return { + kind: 'error', + failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' }, + } + case 'deferred': return { + kind: 'error', + failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' }, + } case 'aborted': return { kind: 'aborted', failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' }, @@ -121,12 +130,14 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) * `finish` chunks (the harness protocol's other error-delivery style). * @param events - one assistant turn's pi-ai event stream. * @param contextWindow - resolved catalog capacity for usage-based overflow detection. + * @param callerSignal - caller cancellation state for setup failures pi-ai emits in-band. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */ export async function* toStreamChunks( events: AsyncIterable, contextWindow?: number, + callerSignal?: AbortSignal, ): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 // in stream order), but we track ids per index for tool calls. @@ -200,7 +211,13 @@ export async function* toStreamChunks( // In-stream error delivery (pi-ai's style) → error finish chunk // (the harness's other sanctioned error path besides throwing). yield { type: 'usage', usage: mapUsage(event.error.usage) } - yield { type: 'finish', reason: mapStopReason(event.error, contextWindow) } + yield { + type: 'finish', + reason: mapStopReason( + callerSignal?.aborted ? { ...event.error, stopReason: 'aborted' } : event.error, + contextWindow, + ), + } return // no default: AssistantMessageEvent is pi-ai's closed union; a new // event type should fail compilation here via tsc's exhaustiveness diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 29e55e0553..b86f1d93ca 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -132,7 +132,7 @@ describe('PiAiAdapter provider routing', () => { expect(server.requests[0]).toMatchObject({ model: 'deepseek-v4-flash', temperature: 0.2, - max_completion_tokens: 77, + max_tokens: 77, thinking: { type: 'enabled' }, reasoning_effort: 'max', }) @@ -483,6 +483,7 @@ describe('provider profile lifecycle', () => { reasoning: { efforts: [ { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('low'), name: 'Low' }, { id: ReasoningEffortId('high'), name: 'High' }, { id: ReasoningEffortId('max'), name: 'Max' }, ], @@ -975,7 +976,10 @@ describe('abort wiring', () => { messages: [], signal: controller.signal, })) chunks.push(chunk) - expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'aborted' } }) + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + reason: { kind: 'aborted', failure: { code: 'ABORTED' } }, + }) }) it('honors a pre-aborted caller signal', async () => { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index dd4d9e3480..7602c4ddd4 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -637,7 +637,7 @@ describe('per-model reasoning efforts', () => { it('narrows a catalog model’s levels in place', () => { const [catalogModel] = getBuiltinModels('deepseek') if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') - expect(getSupportedThinkingLevels(catalogModel as Model)).toEqual(['off', 'high', 'max']) + expect(getSupportedThinkingLevels(catalogModel as Model)).toEqual(['off', 'low', 'high', 'max']) const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] }, @@ -920,6 +920,31 @@ describe('compat switches', () => { }) }) + it('carries private-endpoint stream and reasoning controls', () => { + const models = modelsOf({ + 'acme-baseten': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ + id: 'reasoning-local', + compat: { + supportsFinishReason: false, + thinkingFormat: 'baseten', + chatTemplateArgs: { enable_thinking: { $var: 'thinking.enabled' } }, + supportsThinkingTokenBudget: true, + }, + }], + }, + }, 'acme-baseten') + + expect(models.get('reasoning-local')?.compat).toEqual({ + supportsFinishReason: false, + thinkingFormat: 'baseten', + chatTemplateArgs: { enable_thinking: { $var: 'thinking.enabled' } }, + supportsThinkingTokenBudget: true, + }) + }) + it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { expect(() => resolveProfiles({ 'acme-gateway': { @@ -1044,14 +1069,16 @@ describe('compat switches', () => { })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/) }) - it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => { - expect(() => resolveProfiles({ - 'acme-gateway': { - api: 'openai-completions', - baseURL: 'https://acme.test', - models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }], - }, - })).toThrow(/compat "openRouterRouting", which is not configurable here/) + it('refuses compat keys pi-ai’s catalog owns, pointing at the catalog route', () => { + for (const compat of [{ openRouterRouting: {} }, { supportsAdditionalTools: true }]) { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: compat as never }], + }, + })).toThrow(/which is not configurable here/) + } }) }) diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index 61a2535a9d..ae96b0df7d 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -35,6 +35,17 @@ describe('reasoning schema boundary', () => { it('rejects a thinking format outside the offered set', () => { expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/) }) + + it('accepts Baseten template arguments and completion controls', () => { + expect(configWith({ + compat: { + supportsFinishReason: false, + thinkingFormat: 'baseten', + chatTemplateArgs: { enable_thinking: { $var: 'thinking.enabled' } }, + supportsThinkingTokenBudget: true, + }, + })).not.toThrow() + }) }) describe('modality schema boundary', () => { diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index c540f2d50b..ed4df11a4d 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -758,6 +758,14 @@ describe('mapStopReason / mapUsage', () => { ['stop', { kind: 'stop' }], ['length', { kind: 'max-tokens' }], ['toolUse', { kind: 'tool-calls' }], + ['pending', { + kind: 'error', + failure: { message: 'pi-ai stream for model "deepseek-v4-flash" ended pending', code: 'PI_AI_ERROR' }, + }], + ['deferred', { + kind: 'error', + failure: { message: 'pi-ai deferred response for model "deepseek-v4-flash" is not supported', code: 'PI_AI_ERROR' }, + }], ['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }], ] as const)('maps %s', (stopReason, expected) => { expect(mapStopReason(assistant({ stopReason, content: [{ type: 'text', text: 'ok' }] }))).toEqual(expected) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db280b365d..601785a90f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,7 +441,7 @@ importers: version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) examples: dependencies: @@ -6172,8 +6172,8 @@ importers: specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@earendil-works/pi-ai': - specifier: ^0.82.1 - version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.84.2 + version: 0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8753,7 +8753,7 @@ importers: version: 4.2.0 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -10524,11 +10524,15 @@ packages: search-insights: optional: true - '@earendil-works/pi-ai@0.82.1': - resolution: {integrity: sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==} + '@earendil-works/pi-ai@0.84.2': + resolution: {integrity: sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==} engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-telemetry@0.84.2': + resolution: {integrity: sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -11357,14 +11361,6 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} - '@mistralai/mistralai@2.2.6': - resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -14613,9 +14609,8 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} - openai@6.26.0: - resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} - hasBin: true + openai@6.40.0: + resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} peerDependencies: ws: ^8.18.0 zod: ^3.25 || ^4.0 @@ -15269,8 +15264,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typebox@1.1.38: - resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typebox@1.3.7: + resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} typescript-language-server@5.3.0: resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} @@ -16330,19 +16325,19 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@earendil-works/pi-telemetry': 0.84.2 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) - '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.21.0)(zod@4.4.3) + openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 - typebox: 1.1.38 + typebox: 1.3.7 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -16351,6 +16346,8 @@ snapshots: - ws - zod + '@earendil-works/pi-telemetry@0.84.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -16894,18 +16891,6 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/semantic-conventions': 1.43.0 - ws: 8.21.0 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - optionalDependencies: - '@opentelemetry/api': 1.9.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@mixmark-io/domino@2.2.0': {} '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': @@ -20207,7 +20192,7 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - openai@6.26.0(ws@8.21.0)(zod@4.4.3): + openai@6.40.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 zod: 4.4.3 @@ -20968,7 +20953,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typebox@1.1.38: {} + typebox@1.3.7: {} typescript-language-server@5.3.0: dependencies: @@ -21183,7 +21168,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -21206,43 +21191,13 @@ snapshots: vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@types/node': 22.20.0 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) jsdom: 29.1.1(@noble/hashes@2.3.0) transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 25.9.3 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - jsdom: 29.1.1(@noble/hashes@2.3.0) - transitivePeerDependencies: - - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec6cfd3af9..249fc9495b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,7 +57,7 @@ allowBuilds: minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - - '@earendil-works/pi-ai@0.82.1' + - '@earendil-works/pi-ai@0.84.2' - node-addon-native-custom-loader@0.1.4 - node-addon-require-builtin-darwin-arm64@0.1.4 - node-addon-require-builtin-darwin-x64@0.1.4 From 114846b4ca9643620349578975b6325cb6861ca4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:37:51 +0800 Subject: [PATCH 2/3] test(web): refresh pi-ai provider catalog snapshots --- apps/web/tests/snapshots/models-settings/empty.expected.md | 2 ++ .../snapshots/onboarding-usable-provider/dismissed.expected.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index cea14113ac..54bf1db3c3 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -26,6 +26,7 @@ - option "ant-ling" - option "anthropic" - option "azure-openai-responses" + - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" - option "cloudflare-workers-ai" @@ -50,6 +51,7 @@ - option "openrouter" - option "qwen-token-plan" - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" - option "together" - option "vercel-ai-gateway" - option "xai" diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 84c7358b54..496443b057 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -30,6 +30,7 @@ - option "ant-ling" - option "anthropic" - option "azure-openai-responses" + - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" - option "cloudflare-workers-ai" @@ -54,6 +55,7 @@ - option "openrouter" - option "qwen-token-plan" - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" - option "together" - option "vercel-ai-gateway" - option "xai" From c4f10577b59d11d880194c3201716818b9b0a710 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:57:18 +0800 Subject: [PATCH 3/3] test(llm): cover pi-ai upgrade compatibility --- .../tests/fixtures/pi-ai-defaults.cordis.yml | 33 ++++++++++++ .../headless-agent/tests/headless.snapshot.ts | 52 +++++++++++++++++++ .../src/node/external_packages/pi-ai.ts | 7 +-- packages/llm/llm-pi-ai/src/catalog.ts | 7 +-- packages/llm/llm-pi-ai/src/stream.ts | 3 +- 5 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/pi-ai-defaults.cordis.yml diff --git a/examples/headless-agent/tests/fixtures/pi-ai-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/pi-ai-defaults.cordis.yml new file mode 100644 index 0000000000..c04a761062 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/pi-ai-defaults.cordis.yml @@ -0,0 +1,33 @@ +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ../../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + reasoning: low + modelOverrides: + deepseek-v4-flash: + maxTokens: 1024 + streamIdleTimeoutMs: 1000 + - id: agent-spine + config: + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + cwd: !!js process.cwd() + workspaceContext: false + persona: 'Keyless pi-ai DeepSeek compatibility snapshot.' + - id: persistence + config: + root: './.sessions' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 1f9cbb5743..d55dce7a6c 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -56,6 +56,7 @@ const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', impor const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) +const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.cordis.yml', import.meta.url)) const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') @@ -579,6 +580,57 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => { + const server = await deepseekDefaultsServer() + try { + const result = await runLoaderSmoke({ + label: 'pi-ai DeepSeek compatibility headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-pi-ai-defaults-', + binScript, + libBinScript: binScript, + configPath: piAiDefaultsConfigPath, + binArgs: [ + piAiDefaultsConfigPath, + 'return the deterministic response', + ], + tsconfigPath, + env: { + DEEPSEEK_API_KEY: 'snapshot-key', + DSH_SNAPSHOT_BASE_URL: server.url, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + }) + + expect(result.stderr).toBe('') + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.max_tokens).toBe(1024) + expect(server.requests[0]).not.toHaveProperty('max_completion_tokens') + const header = (parseJsonl(result.stdout) + .map(record => record.event) + .find((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + ))?.data as JsonObject | undefined)?.header as JsonObject | undefined + expect(header?.config).toMatchInlineSnapshot(` + { + "maxTokens": 1024, + "model": "deepseek-v4-flash", + "provider": "deepseek", + "reasoningEffort": "low", + } + `) + expect(header?.adapterDefaults).toEqual({ + maxTokens: true, + reasoningEffort: true, + }) + } finally { + await server.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/pi-ai.ts b/packages/experimental/webworker-runtime/src/node/external_packages/pi-ai.ts index f6c2f830d4..c04331a266 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/pi-ai.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/pi-ai.ts @@ -26,13 +26,14 @@ export const getSupportedThinkingLevels = notImplementedFail(MODULE, 'getSupport /** Context-overflow predicate (unavailable). */ export const isContextOverflow = notImplementedFail(MODULE, 'isContextOverflow') -/** Builtin provider ids of pi-ai 0.82.1, in catalog order. */ +/** Builtin provider ids of pi-ai 0.84.2, in catalog order. */ const BUILTIN_PROVIDER_IDS: readonly string[] = [ - 'amazon-bedrock', 'ant-ling', 'anthropic', 'azure-openai-responses', 'cerebras', + 'amazon-bedrock', 'ant-ling', 'anthropic', 'azure-openai-responses', 'baseten', 'cerebras', 'cloudflare-ai-gateway', 'cloudflare-workers-ai', 'deepseek', 'fireworks', 'github-copilot', 'google', 'google-vertex', 'groq', 'huggingface', 'kimi-coding', 'minimax', 'minimax-cn', 'mistral', 'moonshotai', 'moonshotai-cn', 'nvidia', 'openai', 'openai-codex', 'opencode', - 'opencode-go', 'openrouter', 'qwen-token-plan', 'qwen-token-plan-cn', 'together', + 'opencode-go', 'openrouter', 'qwen-token-plan', 'qwen-token-plan-cn', + 'qwen-token-plan-individual', 'together', 'vercel-ai-gateway', 'xai', 'xiaomi', 'xiaomi-token-plan-ams', 'xiaomi-token-plan-cn', 'xiaomi-token-plan-sgp', 'zai', 'zai-coding-cn', ] diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 62259c7264..a4b7d97ebe 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -91,9 +91,10 @@ export type PiAiThinkingFormat = NonNullable = { 'openai': true, diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 72c93a4634..31c8f151c1 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -130,7 +130,8 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) * `finish` chunks (the harness protocol's other error-delivery style). * @param events - one assistant turn's pi-ai event stream. * @param contextWindow - resolved catalog capacity for usage-based overflow detection. - * @param callerSignal - caller cancellation state for setup failures pi-ai emits in-band. + * @param callerSignal - caller cancellation state; an aborted caller makes any + * in-band terminal error an aborted finish. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */