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 3415bf34c2..9300571e28 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: 3e926756dcf7d98eea7eb327b6722ff242fe245a -2026-08-03-pi-ai-declared-provider-catalog.zh.md: 5385f0f309146b75ab17b0926d700b8fe66a11f8 +2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 +2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f 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 3e926756dc..d75b6bdb91 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`, `reasoning`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning-level spellings, OpenAI-compatibility quirks, and model headers ride the installed entry, because restating them in configuration could not be validated. +- `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`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four 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. @@ -27,7 +27,7 @@ A provider route is a **declaration**, and the installed catalog is its default. The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving. -Resolution fails loud and names the route and model at fault: a model the catalog does not describe needs an explicit `contextWindow` and `maxTokens`; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. +Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own `defaultContextWindow`/`defaultMaxTokens`, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. @@ -41,7 +41,9 @@ pi-ai reports a model with no reasoning metadata as supporting the single level pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. -`ModelsImpl.applyAuth` treats `options.apiKey` as the highest-priority auth override, short-circuiting resolution entirely. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. A catalog route reuses the installed provider's `auth`, which preserves its provider-native ambient discovery for a profile naming no credential. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. +`ModelsImpl.applyAuth` honours `options.apiKey` as the request's key, but only through a provider that declares an api-key method: `resolveProviderAuth` short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with `Provider is not configured` — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. + +A route's auth follows from that. A catalog route keeps the installed provider's own `auth`, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an `api` override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — `openai-codex` authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. ## Alternatives considered @@ -58,8 +60,8 @@ pi-ai's `Models` carries its own credential concept — a `CredentialStore` keye Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata. -What it costs: `settings.yaml` grows for a declared route, because a model the catalog cannot default must state its own capacity. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. +What it costs: `settings.yaml` grows for a declared route, because it must state its endpoint, protocol, and model ids. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. ## Testing -`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. 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 5385f0f309..f8dba9900b 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`、`reasoning`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。思考级别拼写、OpenAI 兼容性怪癖与模型标头沿用已安装条目,因为在配置里重述它们无法被校验。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `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` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -27,7 +27,7 @@ Status: implemented 可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。 -解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 +解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 `defaultContextWindow`/`defaultMaxTokens`,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 @@ -41,7 +41,9 @@ pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适 pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 -`ModelsImpl.applyAuth` 把 `options.apiKey` 视为优先级最高的 auth 覆盖,会整条短路掉解析。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。catalog 路由复用已安装提供方的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 +`ModelsImpl.applyAuth` 会把 `options.apiKey` 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:`resolveProviderAuth` 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 `Provider is not configured` 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。 + +路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 `api` 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式的属性。例外是没有 api-key 方法的 catalog 提供方——`openai-codex` 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 ## Alternatives considered @@ -58,8 +60,8 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred 配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。 -代价是:声明式路由会让 `settings.yaml` 变长,因为 catalog 无法默认的模型必须自报容量。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 +代价是:声明式路由会让 `settings.yaml` 变长,因为它必须自报端点、协议与模型 id。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 ## Testing -`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 423f5bc9cb..c6f6bca511 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -726,7 +726,7 @@ One registered namespace's RAW user section changed, whether or not the resolved Types: [SettingsNamespace](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:167`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) ### `settings/updated` — emit @@ -753,7 +753,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:154`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d2b679657f..cf5addb98c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1813,7 +1813,7 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsPathOp](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:384`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index bc8a9893b8..cf262dc1e9 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.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/core-data-structures/settings.md -settings.md: 08f99b5e65ac4a57cdfff3323c0d9148d379bed8 -settings.zh.md: 08903810b44c293944950a8f5ae2710f45678d60 +settings.md: bd01c1d28407af9cab26f624a054a010e25a3ddd +settings.zh.md: 1cb7f8b507f29f2b6876fd48b4c37284df235e53 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index 08f99b5e65..bd01c1d284 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -37,9 +37,12 @@ interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index 08903810b4..1cb7f8b507 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -37,9 +37,12 @@ interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7fe303e0ef..8b7668cf07 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,8 +38,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:167`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:154`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 89cecdfdec..aa11c863ff 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: 884eadbde73f17ffd50994e09c975cca561d81ed -README.zh.md: af8a620eeccb2b971c4550bdcd7c8af93d5d4f90 +README.md: b8e5ed7b056295d975629ffdf444d353b306fcf7 +README.zh.md: bf8ba431d13f7bf90c75ca2000e0320a8506253b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index a8d425a4bd..07de1c0ace 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -79,16 +79,6 @@ Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseU The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. -## Endpoint interrogation - -The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves. - -A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. - -Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. - -Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. - ## Provider/model routing and replay Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. @@ -151,7 +141,7 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`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. Endpoint interrogation is an explicit action a configuration surface takes over a draft; nothing re-runs it, and adopting its result is a settings write like any other. +- **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. - **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 `apiKey` 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. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 00a20c5e60..0e8895a019 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -79,16 +79,6 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 -## 端点询问 - -插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。 - -点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 - -询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 - -多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 - ## 提供方/模型路由与回放 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 @@ -151,7 +141,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。端点询问是配置界面针对草稿主动发起的动作;没有任何环节会重跑它,采纳其结果与任何其他 settings 写入无异。 +- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 9b24c90a71..365c3901a5 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -40,6 +40,7 @@ import { import type { GenerateOptions, LlmModelInfo, + LlmProviderInfo, LlmResolvedModelInfo, ReasoningEffortId as ReasoningEffortIdType, ResolvedRetryPolicy, @@ -196,6 +197,13 @@ export class PiAiAdapter extends LlmAdapter { return resolved } + override providerInfo(provider: string): LlmProviderInfo { + // The configured name, not the route key: `displayName` exists so a + // deployment can label a route, and a label only the configuration surface + // reads would leave every selector showing the raw key. + return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider } + } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { return this.current().profiles.get(provider)?.retryPolicy } diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 2ac66b5a5e..173b84dd7d 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -196,6 +196,14 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // the model's capability and stays out of request defaults. if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens) return { + // The installed entry lays the floor, and the fields below override it. + // Enumerating instead would silently drop every `Model` field this + // package does not model — reasoning-level spellings, compatibility + // quirks, model headers, and whatever a pi-ai upgrade adds next. That is + // not hypothetical: `headers` reached this file only after an nvidia + // route lost it, and a rebuild keeps re-earning that bug on every + // upgrade. + ...base, id: entry.id, name: entry.name ?? base?.name ?? entry.id, api, @@ -209,12 +217,6 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { cost: base?.cost ?? NO_COST, contextWindow, maxTokens, - // Catalog-only metadata: reasoning-level spellings and OpenAI-compatibility - // quirks have no configuration surface, so they ride the catalog entry or - // are absent for a model pi-ai has never described. - ...base?.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: base.thinkingLevelMap }, - ...base?.compat === undefined ? {} : { compat: base.compat }, - ...base?.headers === undefined ? {} : { headers: base.headers }, } }) return { models, configuredMaxTokens } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 0406a6906b..7473dbb7ae 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -266,6 +266,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, models: catalog.models, + namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined, }), }) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 96fd2f1d8a..e55427e302 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -69,7 +69,14 @@ const NS = settingsNamespace('llm-pi-ai') */ function registrationFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] - .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + // `displayName` rides along because the registry hands it to every selector + // through `providerInfo()`: a rename that did not re-register would leave + // the old label showing until some unrelated fact happened to change. + .map(([provider, profile]) => ({ + provider, + displayName: profile.displayName, + retryPolicy: profile.retryPolicy, + })) .sort((left, right) => left.provider.localeCompare(right.provider)) } @@ -97,7 +104,7 @@ function directoryEntries( export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config let lastRaw: Config | undefined - let lastGood: ReadonlyMap | undefined + let memoized: ReadonlyMap | undefined /** * The resolved profiles for the current configuration, memoized by the raw * snapshot's identity — which is also what makes the adapter's own snapshot @@ -111,10 +118,10 @@ export function apply(ctx: Context, config: Config): void { */ const profiles = (): ReadonlyMap => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw && memoized !== undefined) return memoized const next = resolveProfiles(raw.providers) lastRaw = raw - lastGood = next + memoized = next return next } profiles() @@ -209,7 +216,18 @@ export function apply(ctx: Context, config: Config): void { current = source }, onChange: () => { - ensureRegistrationFacts() + // Named here rather than left to the settings watcher: `assertServiceable` + // cannot see the llm registry, so a profile claiming a route another + // adapter family owns is stored successfully and only fails at this swap. + // Without its own diagnostic that refusal reaches the operator as a + // generic "settings: watcher failed", naming neither the route nor why it + // is not serving. The previous routes keep serving either way. + try { + ensureRegistrationFacts() + } catch (error) { + ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update') + ctx.logger.error(error) + } // The directory follows the profiles the registry accepted, so a route // that failed to register is not advertised as configurable. A refused // directory swap is contained here for the same reason the registry's diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index d69fd539e6..893199d8fa 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -96,6 +96,41 @@ export interface ProviderSpec { baseURL?: string /** The route's materialized models, in configuration order. */ models: readonly Model[] + /** + * Whether the profile names a credential — a literal key or a reference. + * Only that decides whether {@link routeAuth} adds the harness's own api-key + * method to a catalog provider that offers none; the key itself still arrives + * per request, never at construction. + */ + namesCredential: boolean +} + +/** + * The auth one route resolves its credential through. + * + * A catalog route keeps the installed provider's own auth, which is what + * preserves provider-native ambient discovery for a profile naming no + * credential. That holds even when the profile repoints the protocol: which + * environment a provider reads is a property of the provider, not of the wire + * format its models speak. + * + * The single addition covers a catalog provider that offers no api-key method + * at all. pi-ai resolves a request's `apiKey` override only when the provider + * declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before + * honouring the override), so an OAuth-only provider — `openai-codex` is the + * one the installed catalog ships — would refuse a profile's explicit key with + * `Provider is not configured` before any request went out. Adding the harness + * method beside the provider's own restores that route. A keyless profile adds + * nothing and still reports the honest refusal, because this adapter resolves + * credentials through its own seam and holds no OAuth store to fall back on. + * @param spec - the resolved route facts. + * @param catalog - the installed catalog provider, when pi-ai ships one. + * @returns the auth to construct this route's provider with. + */ +function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] { + if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) } + if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth + return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) } } /** @@ -113,7 +148,7 @@ function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider { id: spec.provider, name: spec.displayName, ...baseUrl === undefined ? {} : { baseUrl }, - auth: base.auth, + auth: routeAuth(spec, base), getModels: () => spec.models, // Delegated rather than copied: the catalog provider stays the receiver, so // an implementation holding state on itself keeps working. @@ -149,7 +184,7 @@ export function buildProvider(spec: ProviderSpec): Provider { id: spec.provider, name: spec.displayName, ...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL }, - auth: { apiKey: harnessApiKeyAuth(spec.displayName) }, + auth: routeAuth(spec, catalog), models: spec.models, api: factory(), }) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 9129d213e2..1aded64568 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -10,6 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { createModels } from '@earendil-works/pi-ai' +import type { Api, Model, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' @@ -195,13 +197,13 @@ describe('hand-declared providers', () => { // endpoint, and headers can carry, so a route naming one would be built // unable to authenticate. expect(supportedProtocols()).not.toContain(api) - expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [] })) + expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true })) .toThrow(/cannot serve; supported protocols are/) }, ) it('rejects a protocol this build cannot serve, and a route that names none', () => { - const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] } + const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true } expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' })) .toThrow(/cannot serve; supported protocols are/) expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/) @@ -430,6 +432,37 @@ describe('catalog routes with per-model configuration', () => { await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(server.paths).toEqual(['/v1/chat/completions']) }) + + it('keeps the catalog provider’s own auth when the route repoints its protocol', () => { + // Which environment a provider reads is a property of the provider, not of + // the wire format its models speak: naming an api must not cost a profile + // its provider-native discovery. + const resolved = resolveProfiles({ openai: { api: 'openai-completions' } }) + expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key') + }) + + it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => { + // pi-ai honours a request's `apiKey` override only when the provider + // declares an api-key method. `openai-codex` ships OAuth alone, so without + // the harness method beside it the route refuses its own configured key as + // `Provider is not configured` before any request goes out. + const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } }) + const provider = resolved.get('openai-codex')?.piProvider + expect(provider?.auth.oauth).toBeDefined() + const models = createModels() + models.setProvider(provider as Provider) + const model = provider?.getModels()[0] as Model + const auth = await models.getAuth(model, { apiKey: 'codex-token' }) + expect(auth?.auth.apiKey).toBe('codex-token') + }) + + it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => { + // Nothing to add: this adapter resolves credentials through its own seam + // and holds no OAuth store, so declaring the provider configured would + // trade a truthful refusal for an endpoint's 401. + const resolved = resolveProfiles({ 'openai-codex': {} }) + expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined() + }) }) describe('resolution snapshots', () => { diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a617a73d7c..64e9f147b1 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -55,9 +55,12 @@ export interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 294c933a89..c6a1e57016 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -120,6 +120,19 @@ describe('registration', () => { expect(scope.get()).toMatchObject({ fontSize: 18 }) }) + it('fails the registration itself when the already-stored section is unserviceable', async () => { + // The other direction of the same contract: `register` resolves inline, so + // at cold start there is no last good value to keep. A stored section the + // owner cannot serve therefore refuses the registration rather than + // mounting an owner over configuration it rejects. + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + })).toThrow(/unreadable/) + }) + it('rejects a duplicate namespace loud', async () => { const { ctx } = await boot() ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)