From 50edee2dab8e81d025028a4b55e97cc575e2d9e8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 15:49:23 +0800 Subject: [PATCH] review: sync the note this supersedes, gate the schema paths, export the modality type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared-provider-catalog note still recorded modalities as closed because "nothing reads them: context.ts keeps only text blocks" — the same reasoning this change disproves. Rewrite that fact in place, keep pricing closed for its own still-current reason, and cross-link both directions. The new resolver cases never crossed the config schema, so neither acceptance path this change adds had an executable gate: config.spec.ts now refuses an unknown modality at both levels, pins the empty route list to the namespace validator the settings seam actually runs, and pins the `[]` materialization the inheritance rule depends on. A catalog.spec.ts case re-asserts every rung through a written settings section and `ctx.llm`, which is what the note's Testing section claimed. Also export PiAiModality from the package entry — it types two public config fields and consumers could not name it — record the unverified declaration as a Known Limitation, and stop sharing one fallback array across a route's models. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +- ...6-08-03-pi-ai-declared-provider-catalog.md | 4 +- ...8-03-pi-ai-declared-provider-catalog.zh.md | 4 +- ...i-route-default-input-modalities.i18n.yaml | 4 +- ...12-pi-ai-route-default-input-modalities.md | 6 +- ...pi-ai-route-default-input-modalities.zh.md | 6 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 1 + packages/llm/llm-pi-ai/README.zh.md | 1 + packages/llm/llm-pi-ai/src/catalog.ts | 2 +- packages/llm/llm-pi-ai/src/config.ts | 8 +-- packages/llm/llm-pi-ai/src/index.ts | 1 + packages/llm/llm-pi-ai/tests/catalog.spec.ts | 41 +++++++++++-- packages/llm/llm-pi-ai/tests/config.spec.ts | 58 +++++++++++++++---- 14 files changed, 109 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 4ba2d1dd8f..98c07f4015 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: 45de54f29e718ed1c9364b03da762700e1282e7b -2026-08-03-pi-ai-declared-provider-catalog.zh.md: 43206bc21dfa74f6ddf0583c72dc4e8be1dbb9e5 +2026-08-03-pi-ai-declared-provider-catalog.md: 571986a94c8547538b44b11510609e0000923537 +2026-08-03-pi-ai-declared-provider-catalog.zh.md: 917c19e77dc1db7fe4dc20acf1d9b280277a76d2 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index 45de54f29e..571986a94c 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Input modalities were opened later, as `input` on an entry plus a route-level `defaultInput`, once the image-admission points turned an unreported modality into a refusal a deployment could not lift ([[2026-08-12-pi-ai-route-default-input-modalities]]); the "nothing reads them" reasoning had described `llm-deepseek`'s serializer rather than this route, whose converter carries images. Pricing stays absent for the original reason: `replay.ts` zeroes pi-ai's cost metadata and no consumer reports spend. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's - **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. -- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later, field by field as a consumer appeared: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them, and [[2026-08-12-pi-ai-route-default-input-modalities]] opened modalities (as `input` and `defaultInput`, not a raw `Model.input` passthrough) once the image-admission points read them. Cost stays closed for the original reason. - **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. - **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index 43206bc21d..917c19e77d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理(reasoning)沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理(reasoning)沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。输入模态后来被开放,形态是条目上的 `input` 加路由级 `defaultInput`——图片准入点使得「未被报告的模态」变成部署无法解除的拒绝之后([[2026-08-12-pi-ai-route-default-input-modalities]]);当初「没有任何读取方」那句论证描述的其实是 `llm-deepseek` 的序列化器,而不是这条路由,它的转换器能携带图片。定价仍因原有理由不出现在配置面:`replay.ts` 把 pi-ai 的成本元数据清零,且没有任何消费方报告开销。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出*能力*,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 -- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来逐字段兑现了,每次都等到出现真实读取方:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;[[2026-08-12-pi-ai-route-default-input-modalities]] 在图片准入点开始读取之后开放了模态(以 `input` 与 `defaultInput` 的形态,而非裸 `Model.input` 直通)。成本仍因原有理由保持关闭。 - **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 - **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml index db35718c1e..4ab07f7f07 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.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/architecture/2026-08-12-pi-ai-route-default-input-modalities.md -2026-08-12-pi-ai-route-default-input-modalities.md: 7997c8af6d73f5e034af4f3f5af989ea7bb5f8da -2026-08-12-pi-ai-route-default-input-modalities.zh.md: 0d53e95d67fbcabc9685cce1643d091ecb6f74d8 +2026-08-12-pi-ai-route-default-input-modalities.md: efd20b2cd73979208bb777fa42536bc5b918e29e +2026-08-12-pi-ai-route-default-input-modalities.zh.md: 069a7916c8d4ffe738ff910a851e0a9cd1f66d0a diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md index 7997c8af6d..efd20b2cd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md @@ -10,7 +10,7 @@ Nothing in `settings.yaml` could describe a hand-declared pi-ai model as accepti The harness treats an omitted modality as negative capability, and three admission points act on it before any request is built: model selection refuses to switch into a session that already holds images, prompt admission refuses an image, and `read_image` refuses to read one. Their diagnostics tell the user to select an image-capable model — advice with no reachable referent, because no configuration key could make a hand-declared model image-capable. The route was closed at the metadata, not at the capability: the request converter and every pi-ai wire protocol carry images, and `llm-pi-ai`'s own stream guard is the only thing that would have stopped one. -The assumption was justified in the source as the adapter's real capability rather than a deployment choice. That justification described the DeepSeek chat-completions adapter, whose serializer genuinely rejects image blocks, and had never been true of the pi-ai route. +The assumption was justified in the source as the adapter's real capability rather than a deployment choice, and [[2026-08-03-pi-ai-declared-provider-catalog]] recorded the same reasoning when it decided which `Model` fields the configuration surface would expose ("nothing reads them: … `context.ts` keeps only text blocks"). That justification described the DeepSeek chat-completions adapter, whose serializer genuinely rejects image blocks, and had never been true of the pi-ai route. This note supersedes that one on modalities alone; pricing stays closed there for its own, still-current reason. ## Decision @@ -46,6 +46,8 @@ A model that declares images its endpoint does not serve is not caught locally ## Testing -`packages/llm/llm-pi-ai/tests/catalog.spec.ts` covers each rung of the chain and both readings of an empty list: one route mixing an undeclared model with entry-declared text-only and vision models, a route default answering an undeclared model while an entry still outranks it, a catalog vision model keeping its modalities under a narrower route default, an entry's `[]` inheriting rather than emptying, and the route's `[]` refused. The reported metadata is asserted through a real `ctx.llm.listModels` composition. +`packages/llm/llm-pi-ai/tests/catalog.spec.ts` covers each rung of the chain and both readings of an empty list at the resolver: one route mixing an undeclared model with entry-declared text-only and vision models, a route default answering an undeclared model while an entry still outranks it, a catalog vision model keeping its modalities under a narrower route default, an entry's `[]` inheriting rather than emptying, and the route's `[]` refused. A separate case re-asserts every rung end to end — a written settings section, the plugin's own registration, and `ctx.llm.listModels` / `resolveModelInfo` — so a break between the document and `LlmModelInfo` cannot pass. + +`config.spec.ts` holds the schema boundary: an unknown modality refused at both levels, the empty route list accepted by the schema and refused by the namespace validator that the settings seam actually runs, and the `[]` materialization for an absent array that the inheritance rule depends on. No keyless snapshot lane exercises a pi-ai route: the snapshot examples drive `dsh-llm-replay`, which declares modalities directly in its configuration, and a pi-ai route needs a live endpoint whose port a static `cordis.yml` cannot name. The admission points this change feeds are already covered there through that provider (`examples/acp-agent/image.cordis.snapshot.yml` and `image-text-route.cordis.snapshot.yml`) and are unaffected — what changed is what one adapter reports, not how a gate reads it. diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md index 0d53e95d67..069a7916c8 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md @@ -10,7 +10,7 @@ Status: implemented Harness 把缺失的模态当作否定能力,并有三个准入点在构造任何请求之前就依据它行动:模型选择拒绝切换进已含图片的会话,prompt 准入拒绝图片,`read_image` 拒绝读取。它们的诊断让用户去选一个支持图片的模型——这条建议没有可达的指向对象,因为没有任何配置键能让手写模型变得支持图片。这条路封死在元数据上而非能力上:请求转换器和每种 pi-ai 线路协议都能携带图片,`llm-pi-ai` 自身的流前置检查才是唯一会拦下它的东西。 -源码中把这一假定论证为适配器的真实能力而非部署选择。该论证描述的是 DeepSeek chat-completions 适配器——它的序列化器确实拒绝 image block——对 pi-ai 路由从来就不成立。 +源码中把这一假定论证为适配器的真实能力而非部署选择,而 [[2026-08-03-pi-ai-declared-provider-catalog]] 在决定配置面公开哪些 `Model` 字段时记录了同一套论证(「没有任何读取方:…… `context.ts` 只保留文本块」)。该论证描述的是 DeepSeek chat-completions 适配器——它的序列化器确实拒绝 image block——对 pi-ai 路由从来就不成立。本 note 仅在模态一项上取代那一条;定价在那里依然因其自身、仍然成立的理由保持关闭。 ## 决策 @@ -46,6 +46,8 @@ DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其 ## 测试 -`packages/llm/llm-pi-ai/tests/catalog.spec.ts` 覆盖了这条链的每一级以及空列表的两种读法:一条路由上未声明的模型与条目声明的纯文本、视觉模型并存,路由默认值为未声明的模型作答而条目仍然压过它,catalog 视觉模型在更窄的路由默认值下保住自身模态,条目的 `[]` 走继承而非清空,以及路由的 `[]` 被拒绝。报出的元数据经由真实 `ctx.llm.listModels` 组合断言。 +`packages/llm/llm-pi-ai/tests/catalog.spec.ts` 在 resolver 层覆盖了这条链的每一级以及空列表的两种读法:一条路由上未声明的模型与条目声明的纯文本、视觉模型并存,路由默认值为未声明的模型作答而条目仍然压过它,catalog 视觉模型在更窄的路由默认值下保住自身模态,条目的 `[]` 走继承而非清空,以及路由的 `[]` 被拒绝。另有一条用例把每一级端到端复验一遍——写入的 settings 段、插件自身的注册、以及 `ctx.llm.listModels` / `resolveModelInfo`——因此文档与 `LlmModelInfo` 之间若有断点无法蒙混过关。 + +`config.spec.ts` 负责 schema 边界:两个层级上的未知模态拒绝、路由空列表被 schema 接受而由 settings seam 真正运行的命名空间校验器拒绝,以及继承规则所倚赖的「缺省数组物化为 `[]`」这一事实。 没有任何无密钥 snapshot 通道会跑 pi-ai 路由:snapshot 示例驱动的是 `dsh-llm-replay`,它在自己的配置里直接声明模态,而 pi-ai 路由需要一个真实端点,其端口是静态 `cordis.yml` 无法写出的。本次变更所供给的那些准入点已经通过该提供方在那里得到覆盖(`examples/acp-agent/image.cordis.snapshot.yml` 与 `image-text-route.cordis.snapshot.yml`)且不受影响——改变的是某个适配器报告什么,而非门禁如何读取它。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index e79d836cdf..58bfb8b7c0 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: b8276e3b9a77cfa23ad32733a8b3754d3bfd8a9a -README.zh.md: 94387c879f04be9bfe09c1518b50540e4d79ba84 +README.md: c68db532e94c1b0e956d933fbdcf51e5bb63dd29 +README.zh.md: f0f3f7f9f4abd3578cc6e6d1c496df23f5976a3d diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index b8276e3b9a..c68db532e9 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -194,6 +194,7 @@ Recorded response content appends to the next request and does not invalidate it - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. +- **A modality declaration is not verified, and over-claiming outlives the turn** — nothing interrogates an endpoint for what it accepts, so a model declaring `image` its gateway does not serve is refused by the provider mid-turn rather than here. Prompt admission commits the user message durably before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is another image-capable model, a fork before the image, or a new session; rolling an unconsumed image message back out of the log on a failed send is deferred. - **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 94387c879f..f0f3f7f9f4 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -194,6 +194,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 +- **模态声明不经验证,且多声明的后果超出本轮**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型不会在这里被拦下,而是由提供方在轮次中途拒绝。prompt 准入在构造请求之前就把用户消息持久化提交,于是被拒绝的图片留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是换一个确实支持图片的模型、fork 到图片之前,或开启新会话;发送失败时把尚未消费的图片消息从日志中回滚出去这件事已暂缓。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 519bb8f0f9..2cc2debafc 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -510,7 +510,7 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - input: declaredInput(entry.input) ?? base?.input ?? request.defaultInput, + input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput], cost: base?.cost ?? NO_COST, contextWindow, maxTokens, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7c4bd2b448..4e8e032df0 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -324,10 +324,10 @@ export function resolveProfiles( ) } // Detached from the configuration object because pi-ai types `Model.input` - // mutable and every model falling through to it is handed this array. The - // schema's explicit default covers an absent key, so an empty list here is - // always one someone typed — and unlike an entry's, nothing below it can - // answer instead — so it is refused rather than read as "no answer". + // mutable. The schema's explicit default covers an absent key, so an empty + // list here is always one someone typed — and unlike an entry's, nothing + // below it can answer instead — so it is refused rather than read as "no + // answer". const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT] if (defaultInput.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 199c98bff3..47dee4e8db 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -71,6 +71,7 @@ export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' export type { PiAiCompatProfile, + PiAiModality, PiAiModelOverride, PiAiModelProfile, PiAiProviderProfile, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 620c0ee852..79b00c8356 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -212,10 +212,6 @@ describe('hand-declared providers', () => { const inputOf = (route: string, id: string): readonly string[] | undefined => resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input - // Nothing can interrogate the endpoint, so an undeclared model on a route - // that names no default claims only the modality every supported protocol - // carries. Claiming images instead would admit one the provider then - // rejects mid-turn, after the durable message is already in the log. expect(inputOf('acme-gateway', 'bare')).toEqual(['text']) expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image']) expect(inputOf('acme-gateway', 'deaf')).toEqual(['text']) @@ -224,6 +220,43 @@ describe('hand-declared providers', () => { expect(inputOf('anthropic', vision.id)).toEqual(vision.input) }) + it('carries a written modality declaration all the way to the seam’s model metadata', async () => { + // The resolver-level cases above cannot see a break between the settings + // document and `LlmModelInfo`, so each rung is asserted once more through + // a written section, the plugin's own registration, and `ctx.llm`. + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }], + }, + 'vision-gateway': { + api: 'openai-completions', + baseURL: 'https://vision.test/v1', + defaultInput: ['text', 'image'], + models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }], + }, + 'anthropic': { defaultInput: ['text'] }, + }, + }) + + const listed = async (provider: string): Promise> => + Object.fromEntries((await ctx.llm.listModels(provider)).map(model => [model.id, model.inputModalities])) + + expect(await listed('acme-gateway')).toEqual({ bare: ['text'], seeing: ['text', 'image'] }) + expect(await listed('vision-gateway')).toEqual({ bare: ['text', 'image'], deaf: ['text'] }) + expect((await ctx.llm.resolveModelInfo('acme-gateway', 'seeing')).inputModalities).toEqual(['text', 'image']) + + // A catalog vision model keeps what the catalog records even under a + // narrower route default: the route value is a fallback, not an override. + const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image')) + if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model') + expect((await ctx.llm.resolveModelInfo('anthropic', vision.id)).inputModalities).toEqual(vision.input) + }) + it('reads an entry’s empty modality list as no answer, and the route’s as unserviceable', () => { // Absent and empty are the same request on an entry, exactly as they are // for the route's `models` list — which matters because the config schema diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts index 607511c80b..55444228de 100644 --- a/packages/llm/llm-pi-ai/tests/config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from 'vitest' -import { Config } from '../src/config.ts' +import { assertServiceable, Config } from '../src/config.ts' + +/** Validate one hand-declared route, with the caller's fields layered onto it. */ +const routeWith = (profile: Record): (() => unknown) => + () => Config({ + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + ...profile, + }, + }, + }) + +/** Validate that route with the caller's fields on its single model entry. */ +const configWith = (model: Record): (() => unknown) => + routeWith({ models: [{ id: 'm', ...model }] }) describe('reasoning schema boundary', () => { - const configWith = (model: Record): (() => unknown) => - () => Config({ - providers: { - 'acme-gateway': { - api: 'openai-completions', - baseURL: 'https://acme.test', - models: [{ id: 'm', ...model }], - }, - }, - }) - it('rejects a level pi-ai does not know at the write that produced it', () => { expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/) expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow() @@ -30,3 +36,31 @@ describe('reasoning schema boundary', () => { expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/) }) }) + +describe('modality schema boundary', () => { + it('rejects a modality pi-ai does not know, at either level', () => { + expect(configWith({ input: ['audio'] })).toThrow(/expected/) + expect(routeWith({ defaultInput: ['text', 'audio'] })).toThrow(/expected/) + }) + + it('refuses a route whose models could accept nothing', () => { + // The pair the settings seam runs: the schema accepts the empty list as + // well-typed, and the namespace validator is what refuses it. Asserting + // only the schema would report this route as writable. + expect(routeWith({ defaultInput: [] })).not.toThrow() + expect(() => { assertServiceable(routeWith({ defaultInput: [] })() as Config) }) + .toThrow(/defaultInput must name at least one modality/) + }) + + type Materialized = { + providers: Record + } + + it('materializes an absent entry list as empty and an absent route list as text', () => { + // The empty-list inheritance rule exists because of exactly this: an entry + // that declares nothing reaches resolution as `[]`, not as `undefined`. + const absent = configWith({})() as Materialized + expect(absent.providers['acme-gateway']?.models?.[0]?.input).toEqual([]) + expect(absent.providers['acme-gateway']?.defaultInput).toEqual(['text']) + }) +})