Merge remote-tracking branch 'origin/master' into worktree/provider-credential-lifecycle

# Conflicts:
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/ModelsSection.tsx
#	packages/client/ui-models/src/client/ProviderEditor.tsx
This commit is contained in:
Yichen Jiang
2026-08-06 16:54:33 +08:00
344 changed files with 12248 additions and 1317 deletions

View File

@@ -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: 75b2136315aed758f18f7fe82afcd4903f4a7b98
README.zh.md: ea67250549f1d23d48455fd185283b00183dd538
README.md: af0e952dd8dbd9767b98229ee6b87262007d6738
README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c

View File

@@ -2,19 +2,20 @@
English | [中文](README.zh.md)
Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog.
Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` against that route's configured catalog. A route naming an installed pi-ai provider inherits its endpoint, wire protocol, and model catalog as defaults and overrides them field by field; a route pi-ai does not ship is declared outright, so an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog is configuration rather than a code change.
The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal.
The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `supportedProtocols()`; profile resolution, catalog materialization, provider construction, replay conversion, and stream conversion remain package-internal.
## Config
Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
```yaml
- id: llm
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
# Catalog route: endpoint, protocol, and models all come from pi-ai.
openai:
apiKeyEnv: OPENAI_API_KEY
baseURL: https://proxy.example.com:8443
@@ -26,34 +27,77 @@ Configure credentials and deployment-specific transport settings per provider, k
initialDelayMs: 500
maxDelayMs: 10000
jitterRatio: 0.1
# Catalog route with its catalog narrowed to one model and that model's
# capacity corrected; every unset field still comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
streamIdleTimeoutMs: 300000
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
headers:
X-Deployment: production
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
```
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), so configuration surfaces can offer the full catalog before any route exists. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
## Catalog resolution
A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent.
A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap.
Resolution still fails loud, naming the offending route and model, when a route cannot be served at all: a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list of uniquely-identified models. That resolution runs inside the section schema, so an unserviceable profile is refused **where it is written**`settings.mutate` answers `settings-rejected` naming the route and model — rather than being stored and then quietly disabling every route in the namespace. The settings seam keeps a namespace's last good value for an already-stored section that fails, so this cannot strand a deployment. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing.
`baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions.
`supportedProtocols()` is deliberately narrower than pi-ai's full streaming API set: it holds only the protocols a profile can *completely* describe with a key, an endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and application-default credentials, Azure needs provider environment plus an api-version, and Codex authenticates through OAuth — offering those would hand back a route that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused.
## Dynamic configuration (settings + credentials)
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 provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, 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, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
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.
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`.
Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
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.
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all.
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
The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
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.
Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives.
The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
@@ -109,7 +153,9 @@ 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).
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
- **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.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.

View File

@@ -2,19 +2,20 @@
[English](README.md) | 中文
基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM大语言模型seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`
基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM大语言模型seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile针对该路由已配置的 catalog 解析 `GenerateOptions.model`点名了已安装 pi-ai 提供方的路由会继承其端点、协议格式与模型 catalog 作为默认值并逐字段覆盖pi-ai 未提供的路由则整体声明出来,因此接入 OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,都属于配置而非改代码。
包根入口导出 Cordis 插件契约`PiAiAdapter`profile 解析、模型构造、回放转换和流转换保留在包内部。
package根入口导出 Cordis 插件契约`PiAiAdapter``supportedProtocols()`profile 解析、catalog 物化、提供方构造、回放转换和流转换保留在包内部。
## 配置
按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型
```yaml
- id: llm
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
# Catalog route: endpoint, protocol, and models all come from pi-ai.
openai:
apiKeyEnv: OPENAI_API_KEY
baseURL: https://proxy.example.com:8443
@@ -26,34 +27,77 @@
initialDelayMs: 500
maxDelayMs: 10000
jitterRatio: 0.1
# Catalog route with its catalog narrowed to one model and that model's
# capacity corrected; every unset field still comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
streamIdleTimeoutMs: 300000
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
headers:
X-Deployment: production
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
```
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,因此配置界面可以在任何路由存在之前就提供完整 catalog。哪些适配器存在归组合面哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
## Catalog 解析
profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩充它省略它或留空则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id``name``contextWindow``maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。
条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`262,144`defaultMaxTokens`32,768因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。
路由完全无法服务时解析仍会失败得响亮并点名出问题的路由与模型catalog 未提供的路由需要 `api``baseURL`,以及一个由唯一标识的模型组成的非空 `models` 列表。该解析在分节 schema 内部运行,因此无法服务的 profile 会在**写入之处**被拒绝——`settings.mutate``settings-rejected` 点名路由与模型——而不是先存下来、再悄悄让该 namespace 下每条路由失效。对于已经存下的、在此失败的分节settings seam 会保留该 namespace 上一份可用值,因此这不会把部署卡死。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。
`baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。
`supportedProtocols()` 刻意窄于 pi-ai 的完整流式 API 集合:它只保留 profile 能用密钥、端点与标头**完整描述**的那些协议。Bedrock 要用 AWS 凭据与 region 做 SigV4 签名Vertex 需要 project、location 与应用默认凭据Azure 需要提供方环境外加 api-versionCodex 走 OAuth——提供它们只会交回一个无法完成认证的路由。catalog 路由仍可经自己的 provider 抵达这些协议;被拒绝的只有显式覆盖。
## 动态配置settings + credentials
适配器经由一个 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 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。
适配器通过 `ctx.llm.listModels(provider)` 公开每已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh``max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID提供方模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此不具备推理reasoning能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh``max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID提供方模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。
受支持的 profile 字段是 `apiKey``apiKeyEnv``baseURL``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准
**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `apiKey``apiKeyEnv``displayName``api``baseURL``models``defaultContextWindow``defaultMaxTokens``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 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 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey``apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 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`,与请求发出之前被取消一致。
## 提供方/模型路由与回放
所选 pi-ai catalog descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型harness 适配器不会按模型名称硬编码端点选择
每次解析产出一份**不可变**快照——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
凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储harness 也保住了自己失败得响亮的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。
所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型harness 适配器不会按模型名称硬编码端点选择。
成功的 assistant 响应会在自身持久提供方/模型溯源旁存储经版本化的无损 JSON 回放状态。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
@@ -109,7 +153,9 @@ 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**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点
- **路由的 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因此适配器会拒绝该字段。
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
- **无法获取提供方 HTTP 状态**pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。

View File

@@ -1,17 +1,33 @@
/**
* Generic pi-ai-backed implementation of the Harness LLM seam.
*
* Each resolution produces one **immutable** snapshot — the profiles plus a
* `Models` collection holding the `Provider` each route built — and an
* operation captures a whole snapshot before its first `await`. A
* configuration change builds a *new* collection rather than mutating the one
* in use, because `Models.streamSimple()` is lazy: it resolves the provider
* 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 with a provider that no longer exists. This is
* what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
* way down: switching models mid-reply takes effect on the next step, never
* inside the one in flight.
*
* Credentials stay outside that collection. The harness resolves a route's key
* through its own seam and passes it as the request's `apiKey` option, which
* pi-ai treats as the highest-priority auth override — so `Models` never holds
* a credential store and the harness keeps its fail-loud reference semantics.
*
* @module dsh-llm-pi-ai/adapter
*/
import { streamSimple } from '@earendil-works/pi-ai/compat'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type {
Api,
Model,
Models,
ModelThinkingLevel,
MutableModels,
SimpleStreamOptions,
ThinkingLevel,
} from '@earendil-works/pi-ai'
@@ -24,6 +40,7 @@ import {
import type {
GenerateOptions,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
ReasoningEffortId as ReasoningEffortIdType,
ResolvedRetryPolicy,
@@ -34,35 +51,29 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** One resolution's frozen view: the profiles and the collection built from them. */
interface PiAiSnapshot {
/** The resolved profiles this collection was built from, used as its identity. */
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
/** Providers for exactly those profiles; never mutated once published. */
models: Models
}
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
export interface PiAiAdapterOptions {
/** Current validated profiles by provider route; called once per operation. */
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
/**
* Resolve the credential for one already-resolved profile; called once per
* stream call and frozen for that call. `undefined` defers to pi-ai's
* provider-native ambient discovery, which the plugin allows only for a
* profile naming no credential at all; a named reference that misses throws
* `LlmError` `MISSING_CREDENTIAL` rather than falling back.
* stream call and frozen for that call. `undefined` defers to the route's own
* pi-ai auth, which for an installed catalog route is its provider-native
* ambient discovery; the plugin allows that only for a profile naming no
* credential at all, because a named reference that misses throws `LlmError`
* `MISSING_CREDENTIAL` rather than falling back.
*/
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
}
/**
* Resolve a catalog model dynamically and apply only the configured endpoint
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolvePiModel(
profile: ResolvedPiAiProviderProfile,
modelId: string,
): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
}
return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL }
}
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: ResolvedPiAiProviderProfile,
@@ -97,6 +108,39 @@ function resolveReasoningLevel(
)
}
/**
* Selectable reasoning efforts for one model, or nothing at all.
*
* A model that carries no reasoning metadata — every hand-declared one, and
* every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
* supporting the single level `off`. Passing that through would offer a control
* that cannot do what it says: `off` is translated to *omitting* the reasoning
* option, which for such a model is byte-for-byte the same request as naming no
* effort — so a provider whose own default is to think would keep thinking with
* `off` selected. Omitting `reasoning` entirely is the seam's way of saying the
* capability is unavailable, which leaves the surface offering only the
* provider's default.
* @param model - the resolved model descriptor.
* @param defaultLevel - the profile's configured effort, already validated.
* @returns the `reasoning` field, or an empty object when none can be offered.
*/
function reasoningInfo(
model: Model<Api>,
defaultLevel: ModelThinkingLevel | undefined,
): Pick<LlmResolvedModelInfo, 'reasoning'> | Record<string, never> {
if (!model.reasoning) return {}
const levels = getSupportedThinkingLevels(model)
return {
reasoning: {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) },
},
}
}
/** Merge deployment headers while removing case-insensitive attribution collisions. */
function requestHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
const attribution = attributionHeaders()
@@ -108,28 +152,72 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
}
/**
* pi-ai-backed multi-provider adapter. Model descriptors are resolved for each
* request, so models need not be registered during the Cordis lifecycle.
* pi-ai-backed multi-provider adapter. Each operation reads the current
* profiles, so a configuration change reaches the next request without a
* restart; model descriptors come from the collection those profiles built.
*/
export class PiAiAdapter extends LlmAdapter {
private snapshot: PiAiSnapshot | undefined
constructor(private readonly config: PiAiAdapterOptions) {
super()
}
/**
* The snapshot for the current profiles. Resolution memoizes its result, so
* an unchanged configuration is recognized by identity; a changed one gets a
* brand-new collection, leaving any snapshot an operation already captured
* untouched for as long as that operation holds it.
*/
private current(): PiAiSnapshot {
const profiles = this.config.profiles()
if (this.snapshot?.profiles === profiles) return this.snapshot
const models: MutableModels = createModels()
for (const profile of profiles.values()) models.setProvider(profile.piProvider)
this.snapshot = { profiles, models }
return this.snapshot
}
/** The profile for one route within one snapshot, or the not-owned failure. */
private profileOf(snapshot: PiAiSnapshot, provider: string): ResolvedPiAiProviderProfile {
const profile = snapshot.profiles.get(provider)
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')
}
return profile
}
/** The configured descriptor for one exact route/model pair within one snapshot. */
private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model<Api> {
this.profileOf(snapshot, provider)
const resolved = snapshot.models.getModel(provider, model)
if (resolved === undefined) {
throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL')
}
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.config.profiles().get(provider)?.retryPolicy
return this.current().profiles.get(provider)?.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({
provider,
id: model.id,
name: model.name,
})))
return Promise.resolve().then(() => {
const snapshot = this.current()
this.profileOf(snapshot, provider)
return snapshot.models.getModels(provider).map(model => ({
provider,
id: model.id,
name: model.name,
}))
})
}
override resolveModel(
@@ -137,31 +225,21 @@ export class PiAiAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
'NO_ADAPTER',
))
}
return Promise.resolve().then(() => {
const resolvedModel = resolvePiModel(profile, model)
const levels = getSupportedThinkingLevels(resolvedModel)
const snapshot = this.current()
const profile = this.profileOf(snapshot, provider)
const resolvedModel = this.modelOf(snapshot, provider, model)
const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning)
// Only a cap the deployment configured is a request default; the
// catalog's `maxTokens` sizes the model and stops there.
const configuredMaxTokens = profile.configuredMaxTokens.get(model)
return {
provider,
id: model,
name: resolvedModel.name,
context: { contextWindow: resolvedModel.contextWindow },
reasoning: {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined
? {}
: { defaultEffort: ReasoningEffortId(defaultLevel) },
},
...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens },
...reasoningInfo(resolvedModel, defaultLevel),
}
})
}
@@ -170,14 +248,14 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
// One resolution per stream call: the profile snapshot and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
const profile = this.config.profiles().get(options.provider)
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
const model = resolvePiModel(profile, options.model)
// One capture per stream call, taken before any await: the profile, the
// model descriptor, and the collection all come from the same immutable
// snapshot, and the credential freezes with them. A configuration change
// mid-request builds a separate snapshot, so this request finishes under
// the one it started with and the next call picks up the new one.
const snapshot = this.current()
const profile = this.profileOf(snapshot, options.provider)
const model = this.modelOf(snapshot, options.provider, options.model)
const reasoning = resolveReasoningLevel(
model,
options.reasoningEffort ?? profile.reasoning,
@@ -192,7 +270,7 @@ export class PiAiAdapter extends LlmAdapter {
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
try {
const events = streamSimple(model, toPiContext(options), {
const events = snapshot.models.streamSimple(model, toPiContext(options), {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },

View File

@@ -0,0 +1,223 @@
/**
* Materialization of one provider route's model catalog. The installed pi-ai
* catalog supplies defaults keyed by model id, and a profile's own model
* entries override them field by field, so a route naming a catalog provider
* stays configuration-free while a route pi-ai has never heard of is fully
* describable from `settings.yaml`.
*
* Every pi-ai `Model` field the harness cannot default is required here rather
* than at request time: an unserviceable route fails while its configuration is
* being resolved, which is the earliest point that can name the offending key.
*
* @module dsh-llm-pi-ai/catalog
*/
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai'
/**
* Pricing for a model the installed catalog does not describe. The harness
* never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
* reports spend — so this is the absence of a fact, not a configurable rate.
*/
const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
/**
* Input modalities for a model the installed catalog does not describe. The
* request converter keeps only text blocks, so text is the adapter's actual
* capability rather than a deployment choice.
*/
const TEXT_ONLY: Model<Api>['input'] = ['text']
let providerIndex: Map<string, Provider> | undefined
/**
* Installed catalog providers by id, constructed once. Each entry owns the API
* implementations for its own models, which is why a catalog route reuses this
* provider instead of being rebuilt from parts.
* @returns the catalog provider index.
*/
function catalogProviders(): Map<string, Provider> {
providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider]))
return providerIndex
}
/**
* The installed catalog provider for one route, when pi-ai ships one.
* @param provider - provider route key.
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
*/
export function catalogProvider(provider: string): Provider | undefined {
return catalogProviders().get(provider)
}
/**
* Every provider route the installed pi-ai catalog ships.
* @returns the catalog provider ids.
*/
export function catalogProviderIds(): readonly string[] {
return getBuiltinProviders()
}
/**
* The installed catalog models for one route, indexed by model id.
* @param provider - provider route key.
* @returns catalog models by id; empty for a route pi-ai does not ship.
*/
export function catalogModels(provider: string): Map<string, Model<Api>> {
if (!catalogProviders().has(provider)) return new Map()
const models = getBuiltinModels(provider as BuiltinProvider) as Model<Api>[]
return new Map(models.map(model => [model.id, model]))
}
/** One configured model entry: an id plus the catalog fields it overrides. */
export interface PiAiModelProfile {
/** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */
id: string
/** Display name for selectors; defaults to the catalog name, then the id. */
name?: string
/** Maximum combined request and response context in tokens. */
contextWindow?: number
/**
* Maximum output tokens. Configuring one also makes it this model's
* per-request default; a value inherited from the installed catalog, or the
* route's fallback, is the model's capability and never becomes a request
* default on its own.
*/
maxTokens?: number
}
/** The route-level facts model materialization reads. */
export interface RouteCatalogRequest {
/** Provider route key, stamped onto every materialized model. */
provider: string
/** Wire protocol override; absent defers to each catalog model's own API. */
api?: string
/** Endpoint override; absent defers to the catalog model, then the catalog provider. */
baseURL?: string
/** Configured catalog; absent means the whole installed catalog for this route. */
models?: readonly PiAiModelProfile[]
/** Context capacity for a model neither the entry nor the catalog sizes. */
defaultContextWindow: number
/** Output capability for a model neither the entry nor the catalog sizes. */
defaultMaxTokens: number
}
/** Report a route the deployment cannot serve, naming the settings key at fault. */
function invalid(provider: string, detail: string): never {
throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`)
}
/**
* The one wire protocol a catalog route's shipped models agree on. This is what
* lets a deployment add a model the installed catalog has not caught up with —
* a provider's newest release — without restating the protocol its siblings
* already use. A route whose shipped models disagree (an OpenAI-style catalog
* spanning Responses and Chat Completions) has no such answer, so a model it
* does not describe must name its protocol at the route.
*/
function sharedCatalogApi(defaults: ReadonlyMap<string, Model<Api>>): string | undefined {
const apis = new Set<string>()
for (const model of defaults.values()) apis.add(model.api)
return apis.size === 1 ? [...apis][0] : undefined
}
/** One route's materialized catalog, plus the request caps its profile chose. */
export interface RouteCatalog {
/** The materialized models in configuration order. */
models: readonly Model<Api>[]
/**
* Per-request output caps this profile explicitly configured, by model id.
*
* Separate from `Model.maxTokens` because the two answer different
* questions: pi-ai requires `maxTokens` as the model's output *capability*,
* while the harness seam's `defaultMaxTokens` is a cap the deployment chose
* to send on requests that name none. Materializing a catalog capability as
* a request default would start capping every request at a number nobody
* picked, so only an explicit configuration lands here.
*/
configuredMaxTokens: ReadonlyMap<string, number>
}
/**
* Materialize one route's catalog by merging the installed catalog defaults
* under the configured entries. A route with no configured `models` serves the
* installed catalog unchanged, which is what keeps an existing
* `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
* @param request - the route-level catalog facts.
* @returns the materialized models and the explicitly configured request caps.
*/
export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
const { provider } = request
const defaults = catalogModels(provider)
const providerBaseUrl = catalogProvider(provider)?.baseUrl
// An absent `models` key and an empty one are the same request: the config
// schema materializes `[]` for the absent case, and an empty catalog could
// serve no request anyway, so both mean "serve the installed catalog".
const configured = request.models ?? []
const entries: readonly PiAiModelProfile[] = configured.length > 0
? configured
: [...defaults.values()].map(model => ({ id: model.id }))
if (entries.length === 0) {
invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models'
+ ' must be listed in configuration')
}
const routeApi = sharedCatalogApi(defaults)
const seen = new Set<string>()
const configuredMaxTokens = new Map<string, number>()
const models = entries.map((entry) => {
if (entry.id.length === 0) invalid(provider, 'has a model with an empty id')
if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`)
seen.add(entry.id)
const base = defaults.get(entry.id)
const api = request.api ?? base?.api ?? routeApi
if (api === undefined) {
invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the`
+ ' route\'s api to the wire protocol its endpoint speaks')
}
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
if (baseUrl === undefined) {
invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`)
}
// Capacities fall back to the route's own defaults, so a model listing that
// discloses nothing but ids still yields a serviceable route. The fallback
// is a guess by construction, which is why it is a configurable route field
// rather than a constant buried here.
const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`)
}
const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens
if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`)
}
// Only a value the profile named is a deployment choice; the catalog's is
// 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,
provider,
baseUrl,
// Reasoning rides the installed entry or is absent: a bare boolean would
// make pi-ai advertise effort levels with no `thinkingLevelMap` to spell
// them, and no listing endpoint reports a model's reasoning protocol.
reasoning: base?.reasoning ?? false,
input: base?.input ?? TEXT_ONLY,
cost: base?.cost ?? NO_COST,
contextWindow,
maxTokens,
}
})
return { models, configuredMaxTokens }
}

View File

@@ -3,29 +3,73 @@
* Profiles are a dict keyed by provider route, so the composition base and a
* user-settings layer merge per provider and the route set is structural.
*
* A route key is not required to name an installed pi-ai provider. When it does,
* that provider's endpoint, protocol, display name, and model catalog are the
* profile's defaults and the profile overrides them field by field; when it does
* not, the profile is the whole provider declaration. Resolution therefore ends
* in a built pi-ai `Provider` per route: everything a request needs is decided
* once, while the configuration key that made a route unserviceable can still be
* named in the failure.
*
* @module dsh-llm-pi-ai/config
*/
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { resolveRouteModels } from './catalog.ts'
import type { PiAiModelProfile } from './catalog.ts'
import { buildProvider, supportedProtocols } from './provider.ts'
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Context capacity assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_CONTEXT_WINDOW = 262_144
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_MAX_TOKENS = 32_768
export type { PiAiModelProfile } from './catalog.ts'
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
apiKeyEnv?: string
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
/** Name shown by configuration surfaces; defaults to the route key. */
displayName?: string
/**
* Wire protocol every model on this route speaks. Omission keeps each
* installed catalog model's own protocol, which is why a catalog route needs
* no protocol at all; a route the catalog does not ship must name one.
*/
api?: string
/** Endpoint for this route's models; defaults to the installed catalog's endpoint. */
baseURL?: string
/**
* This route's model catalog. Omission serves the installed catalog for the
* route unchanged; an explicit list replaces it, each entry defaulting its
* unset fields from the installed model of the same id.
*/
models?: PiAiModelProfile[]
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
@@ -47,15 +91,31 @@ export interface PiAiProviderProfile {
}
/** Validated profile with its route stamped and every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy'> {
/** pi-ai provider catalog name and Harness route key (the configuration dict key). */
export interface ResolvedPiAiProviderProfile
extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy' | 'models' | 'displayName'> {
/** Harness route key and the `Models` collection key (the configuration dict key). */
provider: string
/** Resolved display name for selectors and configuration surfaces. */
displayName: string
/** Validated credential reference, when one is configured. */
apiKeyEnv?: CredentialRef
/** Positive finite provider-idle interval after defaulting. */
streamIdleTimeoutMs: number
/** Immutable retry policy captured with this provider route. */
retryPolicy: ResolvedRetryPolicy
/**
* The pi-ai provider this route registers, built from the resolved models.
* Construction happens here so an unserviceable protocol or an underspecified
* model fails with the rest of resolution, leaving the last good route set
* serving requests.
*/
piProvider: Provider
/**
* Per-request output caps this profile explicitly configured, by model id.
* The seam materializes one only into a request that names no cap of its
* own, so a catalog capability must not appear here.
*/
configuredMaxTokens: ReadonlyMap<string, number>
}
/** Plugin configuration: the provider routes this instance owns. */
@@ -75,10 +135,22 @@ const thinkingBudgets = z.object({
high: z.number(),
})
const modelProfile: z<PiAiModelProfile> = z.object({
id: z.string().required(),
name: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
})
const profile = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref'),
displayName: z.string(),
api: z.union(supportedProtocols()),
baseURL: z.string(),
models: z.array(modelProfile),
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
headers: z.dict(z.string()),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
thinkingBudgets,
@@ -96,10 +168,44 @@ export const Config: z<Config> = z.object({
})
/**
* Validate profiles against the installed pi-ai catalog and return a detached
* route-keyed map suitable for per-request reads. This is the one explicit
* resolve step, so an omitted dict resolves to the empty (dormant) route set
* here rather than through a hidden fallback.
* Reject a section this adapter could not serve. Registered as the settings
* namespace's validator, so an unserviceable profile is refused where it is
* *written* — `settings.mutate` answers `settings-rejected` with the offending
* route and model named — instead of being stored and then quietly disabling
* every route in the namespace. It stays a validator rather than a schema
* transform because the schema is also the shape a configuration surface
* renders and the value an absent section resolves to; wrapping it would break
* both.
* @param config - the resolved section to check.
* @throws Error naming the route and model that cannot be served.
*/
export function assertServiceable(config: Config): void {
resolveProfiles(config.providers)
}
/** Reject a pre-release profile shape, naming the replacement. */
function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void {
const legacy = source as PiAiProviderProfile & {
provider?: unknown
maxRetries?: unknown
maxRetryDelayMs?: unknown
}
if ('provider' in legacy) {
throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`)
}
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
throw new Error(
`llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;`
+ ' compose agent recovery with dsh-llm-retry',
)
}
}
/**
* Validate profiles and return a detached route-keyed map suitable for
* per-request reads. This is the one explicit resolve step, so an omitted dict
* resolves to the empty (dormant) route set here rather than through a hidden
* fallback, and each route's models and pi-ai provider are materialized once.
* @param providers - configured provider profiles keyed by route.
* @returns validated profiles in configuration order.
*/
@@ -110,28 +216,19 @@ export function resolveProfiles(
throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
}
const entries = Object.entries(providers ?? {})
const supported = new Set<string>(getBuiltinProviders())
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
for (const [provider, source] of entries) {
const legacy = source as PiAiProviderProfile & {
provider?: unknown
maxRetries?: unknown
maxRetryDelayMs?: unknown
}
if ('provider' in legacy) {
throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key')
}
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
}
rejectRemovedFields(provider, source)
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
}
if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
}
if (source.displayName !== undefined && source.displayName.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`)
}
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0
@@ -140,15 +237,37 @@ export function resolveProfiles(
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
const { apiKeyEnv, retryPolicy, ...rest } = source
// The route key, not the installed provider's own name: the directory has
// always shown route keys, and a catalog route must not silently rename
// itself on every configuration surface just because it gained a profile.
const displayName = source.displayName ?? provider
const catalog = resolveRouteModels({
provider,
...source.api === undefined ? {} : { api: source.api },
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
...source.models === undefined ? {} : { models: source.models },
defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
})
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
resolved.set(provider, {
...rest,
provider,
displayName,
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
streamIdleTimeoutMs,
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
configuredMaxTokens: catalog.configuredMaxTokens,
piProvider: buildProvider({
provider,
displayName,
...source.api === undefined ? {} : { api: source.api },
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
models: catalog.models,
namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined,
}),
})
}
return resolved

View File

@@ -0,0 +1,262 @@
/**
* Answering "which models can this provider serve?" for the configuration
* surface's "fetch available models" action.
*
* A route the installed pi-ai catalog ships is answered **from that catalog**,
* with no network call at all: pi-ai's registry is the authoritative list for
* its own providers, and it carries the capacities a listing endpoint would
* not disclose. Only a route the catalog does not describe — a gateway, a
* self-hosted server — is interrogated over the wire.
*
* Neither path is a catalog refresh. Nothing here is stored: the request
* carries a draft the user is still editing, and the reply is candidate
* metadata the surface offers for adoption. `settings.yaml` remains the only
* thing that decides what a route serves.
*
* Only OpenAI-compatible protocols are interrogated. Their listing is the one
* shape a gateway, a self-hosted server, and the official endpoints all agree
* on, which is the case this action exists for; every other protocol reports
* that it cannot be interrogated so the surface falls back to hand-entry
* rather than guessing a response shape.
*
* @module dsh-llm-pi-ai/discovery
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
import { catalogModels } from './catalog.ts'
/**
* Protocols whose model listing this module can read: the two that speak
* OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its
* OpenAI lineage — it authenticates with an `api-key` header and requires an
* `api-version` query — and Codex authenticates through OAuth; guessing at
* either would report an authentication failure as a provider with no models.
* pi-ai's remaining protocols are absent for the same reason.
*/
const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
'openai-completions',
'openai-responses',
])
/**
* Endpoint replies larger than this are refused. The endpoint is whatever URL
* the user typed, so the ceiling holds on the bytes actually read rather than
* on the length the server claims — the same two-stage shape `dsh-web-fetch`
* uses for its own caller-supplied URLs, except that a truncated model listing
* is not parseable, so overflow rejects instead of truncating.
*/
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
/** One entry of an OpenAI-compatible `GET /models` reply. */
interface ListingEntry {
id?: unknown
/** Common gateway extensions; absent from the official listings. */
name?: unknown
display_name?: unknown
context_window?: unknown
context_length?: unknown
max_tokens?: unknown
max_output_tokens?: unknown
}
/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
function capacity(...candidates: readonly unknown[]): number | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate
}
return undefined
}
/** A non-empty string field of a listing entry, or `undefined`. */
function label(...candidates: readonly unknown[]): string | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) return candidate
}
return undefined
}
/**
* Join the endpoint base with the listing path. The base 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 instead of losing
* them to `URL` resolution.
*/
function listingUrl(baseURL: string): string {
return `${baseURL.replace(/\/+$/, '')}/models`
}
/**
* Read a reply body, refusing one that outgrows the ceiling. A declared length
* is checked first so an honest server is turned away without transferring
* anything; the accumulated total is what actually enforces the bound, because
* a server that under-declares (or streams) tells us nothing up front.
*/
async function readBounded(response: Response, url: string): Promise<string> {
const oversized = (): LlmError =>
new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED')
const declared = Number(response.headers.get('content-length') ?? Number.NaN)
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
await response.body?.cancel()
throw oversized()
}
/* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
if (response.body === null) return ''
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) throw oversized()
chunks.push(value)
}
} finally {
/* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
await reader.cancel().catch(() => {
// Cancel after a drained read, or after this function walked away from
// an oversized one, is cleanup; the reply is already decided either way.
})
}
const body = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(body)
}
/**
* Read one OpenAI-compatible listing reply. Entries without a usable id are
* skipped rather than failing the whole interrogation: a single malformed row
* should not deny the user the rest of a working endpoint's catalog.
*/
function readListing(body: unknown): LlmDiscoveredModel[] {
const data = (body as { data?: unknown } | null)?.data
if (!Array.isArray(data)) {
throw new LlmError(
'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand',
'DISCOVERY_FAILED',
)
}
const models: LlmDiscoveredModel[] = []
for (const raw of data) {
const entry = raw as ListingEntry | null
const id = label(entry?.id)
if (id === undefined) continue
const name = label(entry?.name, entry?.display_name)
const contextWindow = capacity(entry?.context_window, entry?.context_length)
const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens)
models.push({
id,
...name === undefined ? {} : { name },
...contextWindow === undefined ? {} : { contextWindow },
...maxTokens === undefined ? {} : { maxTokens },
})
}
return models
}
/**
* Interrogate one draft provider endpoint for the models it advertises.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @param storedApiKey - the credential the named route already stored, asked
* for only when the draft carries none and only on the path that reaches the
* network. A configuration surface never holds a stored secret — it edits a
* redacted descriptor — so without this an already-configured route would be
* interrogated unauthenticated and answer 401.
* @returns the advertised models in endpoint order.
* @throws LlmError when the protocol has no readable listing, the endpoint
* refuses or fails the request, or the reply is not a model listing.
*/
export async function discoverModels(
request: LlmModelDiscoveryRequest,
storedApiKey?: () => Promise<string | undefined>,
): Promise<readonly LlmDiscoveredModel[]> {
// A catalog route already has its answer, and a better one: the installed
// entries carry context windows and output caps no listing endpoint reports.
if (request.provider !== undefined) {
const installed = catalogModels(request.provider)
if (installed.size > 0) {
return [...installed.values()].map(model => ({
id: model.id,
name: model.name,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}))
}
}
if (request.baseURL === undefined || request.baseURL.length === 0) {
throw new LlmError(
`pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its`
+ " endpoint; set a baseURL, or enter this provider's models by hand",
'DISCOVERY_FAILED',
)
}
// A draft that has not chosen a protocol yet is asked as OpenAI Chat
// Completions: it is the shape a gateway is overwhelmingly likely to speak,
// and the alternative — refusing until the field is filled — would withhold
// the action from the case it exists for. The cost is a misdirected message
// when the endpoint speaks something else (an Anthropic gateway answers 401,
// which reads as a credential problem), and hand-entry remains the way out.
const api = request.api ?? 'openai-completions'
if (!LISTABLE_PROTOCOLS.has(api)) {
throw new LlmError(
`pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`,
'DISCOVERY_UNSUPPORTED',
)
}
const url = listingUrl(request.baseURL)
// A key typed into the form wins: it is the one the user is testing, and it
// may be the replacement for exactly the stored key that is failing. The
// stored one is only asked for here, past the catalog short-circuit and the
// protocol check, so a route answered from the registry costs no credential
// lookup — and no diagnostic about a credential it never needed.
const apiKey = request.apiKey ?? await storedApiKey?.()
let response: Response
try {
response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` },
...attributionHeaders(),
},
...request.signal === undefined ? {} : { signal: request.signal },
})
} catch (error: unknown) {
if (request.signal?.aborted) {
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
}
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error })
}
if (!response.ok) {
throw new LlmError(
`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
'DISCOVERY_FAILED',
)
}
let text: string
try {
text = await readBounded(response, url)
} catch (error: unknown) {
// Cancellation during the body read rejects with the abort reason, which
// may be any value; the caller gets the same coded failure it would have
// for a cancellation before the request went out.
if (request.signal?.aborted) {
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
}
throw error
}
let body: unknown
try {
body = JSON.parse(text)
} catch (error: unknown) {
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error })
}
return readListing(body)
}

View File

@@ -1,10 +1,11 @@
/**
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
* provider routes; requests select a profile by provider and resolve the
* model dynamically from pi-ai's installed catalog. Profile facts resolve per
* request over the optional `llm-pi-ai` user-settings section and the
* optional credential seam, so a changed key, endpoint, or knob reaches the
* next request without a restart; a changed *route set* (or a route's
* provider routes; a route naming an installed pi-ai provider inherits that
* provider's endpoint, protocol, and model catalog as defaults, and a route
* pi-ai does not ship is declared outright. Profile facts resolve per request
* over the optional `llm-pi-ai` user-settings section and the optional
* credential seam, so a changed key, endpoint, model, or knob reaches the next
* request without a restart; a changed *route set* (or a route's
* registration-captured retry policy) re-registers the same adapter instance
* in place.
*
@@ -13,34 +14,49 @@
* name: '@deepseek-ai/dsh-llm-pi-ai'
* config:
* providers:
* # Catalog route: everything but the credential comes from pi-ai.
* openai:
* apiKeyEnv: OPENAI_API_KEY
* retryPolicy:
* mode: normal
* maxRetries: 2
* # Catalog route with the catalog narrowed and one capacity corrected.
* anthropic:
* apiKeyEnv: ANTHROPIC_API_KEY
* openrouter:
* apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1
* models:
* - id: claude-sonnet-4-5
* contextWindow: 200000
* # Hand-declared route: pi-ai ships nothing under this key.
* acme-gateway:
* displayName: Acme Gateway
* apiKeyEnv: ACME_GATEWAY_API_KEY
* api: openai-completions
* baseURL: https://gateway.acme.example/v1
* models:
* - id: acme-large
* name: Acme Large
* contextWindow: 65536
* maxTokens: 4096
* ```
*
* @module @deepseek-ai/dsh-llm-pi-ai
*/
import type { Context } from 'cordis'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts'
import { Config, resolveProfiles } from './config.ts'
import { catalogProviderIds } from './catalog.ts'
import { assertServiceable, Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { discoverModels } from './discovery.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
export { Config } from './config.ts'
export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export { supportedProtocols } from './provider.ts'
export const name = 'llm-pi-ai'
export const inject = ['llm']
@@ -54,33 +70,60 @@ const NS = settingsNamespace('llm-pi-ai')
*/
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): 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))
}
/**
* The configurable-provider directory: every installed catalog route, plus
* every route the current profiles declare. A hand-declared route has no
* catalog entry, so without this union it would have no settings address and
* configuration surfaces could neither show nor edit it.
* @param profiles - the currently resolved provider profiles.
* @returns the directory entries in catalog order, declared routes last.
*/
function directoryEntries(
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
): LlmConfigurableProvider[] {
const entries = new Map<string, LlmConfigurableProvider>()
const declare = (provider: string, displayName: string): void => {
entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] })
}
for (const provider of catalogProviderIds()) declare(provider, provider)
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
return [...entries.values()]
}
/** Register one generic pi-ai adapter for all configured provider routes. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
let lastRaw: Config | undefined
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
let memoized: ReadonlyMap<string, ResolvedPiAiProviderProfile> | 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
* stable across operations that observe no change.
*
* No fallback for an unserviceable snapshot lives here: the section schema
* resolves the whole profile set, so a write that could not be served is
* refused where it is written, and the settings seam keeps a namespace's
* last good value for a stored section that fails. Anything reaching this
* point has already resolved once.
*/
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveProfiles(raw.providers)
lastRaw = raw
lastGood = next
return next
} catch (error) {
// Static composition resolves before anything registers, so this branch
// only sees a live settings snapshot failing catalog or bound checks:
// keep serving the last good profiles and say so once per bad snapshot.
if (lastGood === undefined) throw error
lastRaw = raw
ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section')
ctx.logger.error(error)
return lastGood
}
if (raw === lastRaw && memoized !== undefined) return memoized
const next = resolveProfiles(raw.providers)
lastRaw = raw
memoized = next
return next
}
profiles()
@@ -114,13 +157,46 @@ export function apply(ctx: Context, config: Config): void {
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
// The full installed catalog is configurable from the moment the plugin
// mounts — dormant or not — so configuration surfaces can offer every
// pi-ai provider before any route exists.
ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({
provider,
displayName: provider,
settingsNs: NS,
settingsPath: ['providers', provider],
})))
// pi-ai provider before any route exists. Hand-declared routes join it as
// profiles appear, and leave with them.
let directory: DirectoryRegistrationHandle | undefined
let directoryFacts: unknown
const ensureDirectory = (): void => {
const entries = directoryEntries(profiles())
if (deepEqualJson(entries, directoryFacts)) return
// Atomic replace, never dispose-then-register: a route another adapter
// family already declares (a profile keyed `deepseek-official`) would
// otherwise leave this plugin's whole directory withdrawn and the Models
// page empty. The candidate set is validated first, so a collision keeps
// the previous entries serving and only costs a diagnostic.
if (directory === undefined) {
directory = ctx.llm.registerConfigurableProviders(entries)
} else {
directory.replace(entries)
}
directoryFacts = entries
}
ensureDirectory()
/**
* The credential a named route already resolves, for an interrogation whose
* draft carries none. A route being declared for the first time names no
* profile yet, and a profile that names no credential defers to pi-ai's own
* discovery, so both answer `undefined` and the endpoint is asked
* unauthenticated — the same posture a request to that route would take.
*/
const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
if (provider === undefined) return undefined
const profile = profiles().get(provider)
if (profile === undefined) return undefined
return resolveApiKey(provider, profile)
}
// Interrogating an endpoint is a configuration-time action over a draft, so
// it is offered for the whole namespace rather than per route: the provider
// a surface is adding does not exist yet. The draft is the whole request
// except the credential: a configuration surface edits a redacted descriptor
// and never holds a stored secret, so an already-configured route supplies
// its own here rather than being interrogated unauthenticated.
ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider)))
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a
@@ -153,9 +229,37 @@ export function apply(ctx: Context, config: Config): void {
ensureRegistrationFacts()
installSettingsSection(ctx, NS, Config, config, {
// Refuse an unserviceable section where it is written: without this a
// schema-valid profile the adapter cannot serve would be stored and then
// silently disable every route in this namespace.
validate: assertServiceable,
setSource: (source) => {
current = source
},
onChange: ensureRegistrationFacts,
onChange: () => {
// 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
// is: the previous entries keep serving, and `directoryFacts` stays put
// so returning to a working configuration re-applies.
try {
ensureDirectory()
} catch (error) {
ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update')
ctx.logger.error(error)
}
},
})
}

View File

@@ -0,0 +1,191 @@
/**
* Construction of the pi-ai `Provider` that one configured route registers into
* the adapter's `Models` collection.
*
* Two constructions, one decision: a route the installed catalog ships, whose
* profile does not override the wire protocol, **reuses that catalog provider**
* with its models replaced — the catalog 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 — one pi-ai has never heard of, or a
* catalog route pointed at a different protocol — is built by `createProvider`
* over the protocol table below.
*
* Credentials never reach this module's storage: the harness resolves a route's
* key through `ctx.credentials` before the request enters pi-ai and hands it
* over as a stream option, which `Models` presents to `resolve()` as the
* credential key.
*
* @module dsh-llm-pi-ai/provider
*/
import { createProvider } from '@earendil-works/pi-ai'
import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai'
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
import { catalogProvider } from './catalog.ts'
/**
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
* implementations. Each entry is the factory that pi-ai's matching provider
* factory uses, so a hand-declared route reaches exactly the implementation a
* catalog route would.
*
* The table is deliberately narrow: the protocols a hand-declared route
* actually reaches for today, each completely describable with a key, an
* endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
* region, Vertex needs a project, a location, and application-default
* credentials, Azure needs provider environment plus an api-version, and Codex
* authenticates through OAuth — none of which this configuration shape can
* express, so offering them would hand back a provider that cannot
* authenticate. The remainder are absent for want of a consumer rather than a
* blocker: each is one line here once a deployment needs it. Catalog routes
* still reach every protocol through their own provider; only an explicit
* override is refused.
*/
const PROTOCOLS: Readonly<Record<string, () => ProviderStreams>> = {
'openai-completions': openAICompletionsApi,
'openai-responses': openAIResponsesApi,
'anthropic-messages': anthropicMessagesApi,
}
/**
* Every wire protocol a configured route may name, most-reached first. The
* order is the table's and therefore stable; a configuration surface offering
* a choice presents the first as its default, which is why the protocol a
* hand-declared gateway most often speaks — and the one endpoint interrogation
* can read — leads.
* @returns the supported protocol identifiers.
*/
export function supportedProtocols(): readonly string[] {
return Object.keys(PROTOCOLS)
}
/**
* Api-key auth for a route the harness authenticates itself. `Models` calls
* this after the adapter has already resolved the route's credential, so a
* missing key here is not this layer's failure: a named-but-unresolvable
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
* route naming no credential at all is deliberately unauthenticated. Reporting
* it as configured hands the decision to the protocol, which is where the
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
* one, still insists on a key or an `Authorization` header of its own.
* @param name - display name used as the resolution's status label.
* @returns the api-key auth for a harness-authenticated route.
*/
function harnessApiKeyAuth(name: string): ApiKeyAuth {
return {
name,
resolve: ({ credential }) => Promise.resolve({
auth: credential?.key === undefined ? {} : { apiKey: credential.key },
source: name,
}),
}
}
/** The resolved route facts provider construction reads. */
export interface ProviderSpec {
/** Provider route key; also the `Models` collection key and each model's `provider`. */
provider: string
/** Display name for selectors and status labels. */
displayName: string
/** Wire protocol override; absent means each model keeps its catalog protocol. */
api?: string
/** Endpoint override already applied to {@link models}; kept for provider-level display. */
baseURL?: string
/** The route's materialized models, in configuration order. */
models: readonly Model<Api>[]
/**
* 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) }
}
/**
* Reuse an installed catalog provider with this route's models and identity.
* Model dispatch stays with the catalog provider, so its API implementations,
* compatibility quirks, and ambient credential discovery are preserved exactly.
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
* settings document, and a background refresh would contradict it.
*/
function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider {
// Provider-level `baseUrl` is display metadata: pi-ai routes every request
// through `Model.baseUrl`, which model resolution has already overridden.
const baseUrl = spec.baseURL ?? base.baseUrl
return {
id: spec.provider,
name: spec.displayName,
...baseUrl === undefined ? {} : { baseUrl },
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.
stream: (model, context, options) => base.stream(model, context, options),
streamSimple: (model, context, options) => base.streamSimple(model, context, options),
}
}
/**
* Build the pi-ai provider for one resolved route.
* @param spec - the resolved route facts.
* @returns the provider to register in the adapter's `Models` collection.
* @throws Error when the route names a wire protocol this build cannot serve.
*/
export function buildProvider(spec: ProviderSpec): Provider {
const catalog = catalogProvider(spec.provider)
// A catalog route keeping its catalog protocol reuses the catalog provider;
// an explicit protocol means the deployment is repointing the route at a
// different wire format, which only the protocol table can serve.
if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec)
// Every model on this path carries the route's protocol: model resolution
// requires one for a route the catalog cannot default, and an explicit one
// replaces each catalog model's own. So the route has a single API.
const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api]
if (factory === undefined) {
throw new Error(
`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;`
+ ` supported protocols are ${supportedProtocols().join(', ')}`,
)
}
return createProvider({
id: spec.provider,
name: spec.displayName,
...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL },
auth: routeAuth(spec, catalog),
models: spec.models,
api: factory(),
})
}

View File

@@ -129,6 +129,23 @@ describe('PiAiAdapter provider routing', () => {
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('names a route by its displayName, and by its own key once the profiles drop it', () => {
const adapter = adapterOf({ 'acme-gateway': {
apiKey: 'k',
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
models: [{ id: 'acme-large' }],
} })
expect(adapter.providerInfo('acme-gateway')).toEqual({ id: 'acme-gateway', name: 'Acme Gateway' })
// The registry and the profiles can disagree for a moment: a refused
// registration swap leaves the previous routes serving while resolution
// has already moved on, so a selector may ask about a route the current
// profiles no longer describe. It gets the key rather than nothing.
expect(adapter.providerInfo('departed')).toEqual({ id: 'departed', name: 'departed' })
})
it('reports unsupported stop sequences rather than silently ignoring them', async () => {
const server = await mockServer([])
const ctx = await harness(server.url)
@@ -339,12 +356,11 @@ describe('provider profile lifecycle', () => {
ReasoningEffortId('xhigh'),
ReasoningEffortId('max'),
])
await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1'))
.resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
},
})
// A catalog model without reasoning is the same case as a hand-declared
// one: pi-ai reports the single level `off`, which translates to omitting
// the reasoning option — exactly what naming no effort already does. The
// capability is reported unavailable rather than offering that control.
expect((await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')).reasoning).toBeUndefined()
})
it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => {
@@ -406,12 +422,14 @@ describe('provider profile lifecycle', () => {
expect(server.requests).toHaveLength(0)
})
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
it('validates empty, underspecified, legacy-shaped, and explicitly blank profiles', () => {
// Empty and omitted dicts are the dormant zero-route posture, not errors.
expect(resolveProfiles({}).size).toBe(0)
expect(resolveProfiles(undefined).size).toBe(0)
expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
// A route the installed catalog does not ship is allowed, but it has no
// defaults to fall back on: it must describe its own models.
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/resolves no models/)
// The pre-release array shape and its per-profile provider field fail
// loud with migration directions instead of half-working.
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)

View File

@@ -0,0 +1,576 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
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'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const homes: string[] = []
afterEach(async () => {
await closeMockServers()
await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** A throwaway $DSH_HOME with an empty settings document. */
async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-'))
homes.push(dir)
await writeFile(join(dir, 'settings.yaml'), '')
return dir
}
/** The dormant composition plus a real settings service, as the product mounts it. */
async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(LlmPiAi, config)
return ctx
}
/** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */
function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmPiAi.Config {
return {
providers: {
'acme-gateway': {
apiKey: 'gw-key',
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL,
models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }],
...overrides,
},
},
}
}
async function harness(config: LlmPiAi.Config): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, config)
return ctx
}
describe('hand-declared providers', () => {
it('serves a route pi-ai has never heard of from its own declaration', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(gateway(`${server.url}/v1`))
const result = await assemble(ctx, {
provider: 'acme-gateway',
model: 'acme-large',
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
expect(server.paths).toEqual(['/v1/chat/completions'])
expect(server.headers[0]?.authorization).toBe('Bearer gw-key')
})
it('lists and resolves the declared models rather than a catalog', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
expect(await ctx.llm.listModels('acme-gateway')).toEqual([
{ provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large' },
])
const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')
expect(info).toMatchObject({
provider: 'acme-gateway',
id: 'acme-large',
name: 'Acme Large',
context: { contextWindow: 65_536 },
defaultMaxTokens: 4096,
})
})
it('offers no reasoning control it could not honour', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
// pi-ai reports a model with no reasoning metadata as supporting the single
// level `off`, but `off` is translated to *omitting* the reasoning option —
// byte-for-byte the same request as naming no effort — so a provider whose
// own default is to think would keep thinking with `off` selected. The
// capability is reported unavailable instead of offering that control.
expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined()
// A catalog route is unaffected: its models carry the metadata that makes
// `off` actually disable thinking.
const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
.toContain('off')
})
it('joins the configurable-provider directory so a settings surface can reach it', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
provider: 'acme-gateway',
displayName: 'Acme Gateway',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'acme-gateway'],
})
})
it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
// A listing endpoint that discloses nothing but ids still yields a
// serviceable route.
models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }],
},
'tuned-gateway': {
api: 'openai-completions',
baseURL: 'https://tuned.test',
defaultContextWindow: 4096,
defaultMaxTokens: 256,
models: [{ id: 'bare' }],
},
})
const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
resolved.get(route)?.piProvider.getModels() ?? []
expect(modelsOf('acme-gateway')).toMatchObject([
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
{ id: 'sized', contextWindow: 8192, maxTokens: 512 },
])
// The fallback is a guess, so a deployment whose gateway serves smaller
// models corrects it once for the whole route.
expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }])
// Only an explicitly configured cap is a request default; a fallback is
// the model's capability and stops there.
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined()
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
})
it('rejects a model the route cannot identify', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
expect(declare({ id: '' })).toThrow(/empty id/)
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }],
},
})).toThrow(/more than once/)
})
it('rejects a declaration that names no wire protocol or endpoint', () => {
expect(() => resolveProfiles({
'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
})).toThrow(/needs an api/)
expect(() => resolveProfiles({
'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
})).toThrow(/needs a baseURL/)
})
it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
'refuses %s, whose authentication a profile cannot express',
(api) => {
// These need SigV4 credentials and a region, a project plus ADC, provider
// environment and an api-version, or OAuth — none of which a key, an
// 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: [], 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: [], namesCredential: true }
expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' }))
.toThrow(/cannot serve; supported protocols are/)
expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
})
it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => {
const server = await mockServer([{ events: textEvents }])
// Naming no credential is the deliberately unauthenticated posture — a
// named reference that resolved to nothing would have failed with
// MISSING_CREDENTIAL long before this point. The route resolves as
// configured and the protocol decides: pi-ai's OpenAI-compatible
// implementation wants a key or an Authorization header of its own, and
// says so instead of the harness guessing a placeholder.
const ctx = await harness({
providers: {
'local-llm': {
api: 'openai-completions',
baseURL: `${server.url}/v1`,
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
},
},
})
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: { message: 'No API key for provider: local-llm' },
})
expect(server.requests).toHaveLength(0)
})
it('authenticates an unauthenticated route through a configured header', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness({
providers: {
'local-llm': {
api: 'openai-completions',
baseURL: `${server.url}/v1`,
headers: { Authorization: 'Bearer local' },
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
},
},
})
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
expect(result.finish).toEqual({ kind: 'stop' })
expect(server.headers[0]?.authorization).toBe('Bearer local')
})
it('rejects a capacity that is not a positive integer', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/)
})
it('names the route key when no displayName is configured', () => {
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
},
})
expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway')
expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/)
})
})
describe('catalog routes with per-model configuration', () => {
it('serves the installed catalog untouched when the profile lists no models', async () => {
const server = await mockServer([])
const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
const listed = await ctx.llm.listModels('deepseek')
expect(listed.map(model => model.id).sort())
.toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
})
it('overrides one catalog model field and defaults the rest from the catalog', async () => {
const server = await mockServer([])
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
baseURL: server.url,
models: [{ id: catalogModel.id, contextWindow: 4096 }],
},
},
})
const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)
// The configured field wins and the name still comes from the catalog. The
// catalog's own output cap is the model's capability, not a cap anyone
// chose, so it must not arrive as the request default.
expect(info.context).toEqual({ contextWindow: 4096 })
expect(info.name).toBe(catalogModel.name)
expect(info.defaultMaxTokens).toBeUndefined()
// An explicit list replaces the catalog rather than adding to it.
expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id])
})
it('materializes a request default only from a configured output cap', async () => {
const server = await mockServer([])
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
baseURL: server.url,
models: [{ id: catalogModel.id, maxTokens: 4096 }],
},
},
})
// Configuring the cap is the deployment choosing one, so it becomes the
// default the seam materializes into requests that name none.
expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096)
})
it('adds a model the installed catalog does not describe to a catalog route', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness({
providers: {
deepseek: {
apiKey: 'k',
baseURL: `${server.url}/v1`,
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
},
},
})
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] })
expect(result.finish).toEqual({ kind: 'stop' })
// The catalog route keeps its catalog protocol, so the new model reaches
// the same endpoint shape the shipped models use.
expect(server.paths).toEqual(['/v1/chat/completions'])
})
it('fails an unconfigured model id before any provider request', async () => {
const server = await mockServer([])
const ctx = await harness({
providers: {
deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
},
})
const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } })
expect(server.requests).toHaveLength(0)
})
it('preserves catalog-only model metadata the profile cannot express', () => {
// Some catalog models carry provider-required request headers; overriding a
// capacity must not drop them, because configuration has no way to restate
// them.
const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[])
.find(model => model.headers !== undefined)
if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers')
const resolved = resolveProfiles({
nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
})
const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
expect(model?.headers).toEqual(headered.headers)
expect(model?.contextWindow).toBe(4096)
})
it('delegates both stream methods back to the reused catalog provider', async () => {
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
const built = resolved.get('deepseek')?.piProvider
if (built === undefined) throw new Error('the deepseek route built no provider')
const [model] = built.getModels()
if (model === undefined) throw new Error('the deepseek route resolved no models')
const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] }
// `stream` is interface-required and unused by the harness adapter, which
// only calls `streamSimple`; both must still reach the catalog provider.
for await (const _event of built.stream(model, context, { apiKey: 'k' })) { /* drain */ }
for await (const _event of built.streamSimple(model, context, { apiKey: 'k' })) { /* drain */ }
expect(server.paths).toEqual(['/v1/chat/completions', '/v1/chat/completions'])
})
it('keeps each model its own endpoint when the catalog route declares none', () => {
// `opencode` ships no provider-level endpoint: the address lives on every
// catalog model, so the route resolves without any configured baseURL.
const resolved = resolveProfiles({ opencode: {} })
const models = resolved.get('opencode')?.piProvider.getModels() ?? []
expect(models.length).toBeGreaterThan(0)
expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined()
})
it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
const models = resolved.get('openai')?.piProvider.getModels() ?? []
// The protocol changes for the whole route; each model keeps the catalog
// endpoint it already had.
expect(models.every(model => model.api === 'openai-completions')).toBe(true)
expect(models.every(model => model.baseUrl === 'https://api.openai.com/v1')).toBe(true)
})
it('repoints a catalog route at another wire protocol', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness({
providers: {
// openai's catalog models speak the Responses API; naming the protocol
// explicitly moves the whole route onto Chat Completions.
openai: {
apiKey: 'k',
api: 'openai-completions',
baseURL: `${server.url}/v1`,
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
},
},
})
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(server.paths).toEqual(['/v1/chat/completions'])
})
it('keeps the catalog providers 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<Api>
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', () => {
it('finishes an in-flight request under the configuration it started with', async () => {
const server = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
let release: () => void = () => {}
const held = new Promise<void>((resolve) => { release = resolve })
const adapter = new PiAiAdapter({
profiles: () => current,
// Credential resolution is the real await inside a stream call, and the
// window a configuration change has to land in.
resolveApiKey: async () => { await held; return 'k' },
})
const chunks: StreamChunk[] = []
const inFlight = (async () => {
for await (const chunk of adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [],
})) chunks.push(chunk)
})()
// The route set changes while the request waits, and something else reads
// the adapter meanwhile, which is what would rebuild a shared collection.
current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } })
await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
release()
await inFlight
// The in-flight request keeps its own snapshot: it reaches the endpoint it
// resolved against instead of failing on a provider that no longer exists.
expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'stop' } })
expect(server.paths).toEqual(['/v1/chat/completions'])
})
it('serves the next request from the new configuration', async () => {
const first = await mockServer([{ events: textEvents }])
const second = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } })
const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek', model: 'deepseek-v4-flash', messages: [],
})) { /* drain */ }
}
await drain()
current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } })
await drain()
expect(first.paths).toHaveLength(1)
expect(second.paths).toHaveLength(1)
})
})
describe('configurable-provider directory', () => {
it('keeps the previous directory when a route collides with another adapter family', async () => {
const dir = await home()
const ctx = await bootWithSettings(dir, {})
// Another adapter family owns this route id, exactly as llm-deepseek does.
ctx.llm.registerConfigurableProviders([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
])
const before = ctx.llm.listConfigurableProviders().length
expect(before).toBeGreaterThan(30)
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'deepseek-official': {
apiKey: 'k',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
},
},
})
// The refused swap costs a diagnostic, not the directory: every entry the
// page needs is still declared.
expect(ctx.llm.listConfigurableProviders()).toHaveLength(before)
expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'deepseek-official')?.settingsNs)
.toBe('llm-deepseek')
})
it('replaces its entries atomically as declared routes come and go', async () => {
const dir = await home()
const ctx = await bootWithSettings(dir, {})
const catalogOnly = ctx.llm.listConfigurableProviders().length
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
apiKey: 'k',
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
},
},
})
expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly + 1)
expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName)
.toBe('Acme Gateway')
await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {})
expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly)
})
})

View File

@@ -0,0 +1,313 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { discoverModels } from '../src/discovery.ts'
const servers: Server[] = []
/** Credential variables a test set, cleared so the next one starts unset. */
const touchedEnv: string[] = []
afterEach(async () => {
for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
interface ListingServer {
url: string
paths: string[]
headers: IncomingMessage['headers'][]
}
/**
* A stand-in provider that answers one scripted `GET /models`. `chunks` writes
* without a declared length, which is how a real streamed reply arrives.
*/
async function listingServer(behavior: {
status?: number
body?: string
chunks?: string[]
holdOpenMs?: number
}): Promise<ListingServer> {
const paths: string[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
paths.push(request.url ?? '')
headers.push(request.headers)
if (behavior.chunks !== undefined) {
// No declared length: the ceiling has to hold on what is read.
response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
for (const chunk of behavior.chunks) response.write(chunk)
if (behavior.holdOpenMs === undefined) { response.end(); return }
// Left open so a caller's cancellation lands while the body is still
// being read rather than after it completed.
setTimeout(() => { response.end() }, behavior.holdOpenMs)
return
}
const body = behavior.body ?? '{}'
response.writeHead(behavior.status ?? 200, {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body)),
})
response.end(body)
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, paths, headers }
}
/** A bare dormant mount: discovery is offered whether or not a route exists. */
async function harness(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {})
return ctx
}
describe('catalog-route model discovery', () => {
it('answers from the installed registry, with capacities and no network call', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) })
const ctx = await harness()
const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek', baseURL: server.url })
// pi-ai's own registry is the authority for its own providers, and it
// carries what a listing endpoint would not disclose.
expect(models.map(model => model.id).sort())
.toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
expect(models.every(model => (model.contextWindow ?? 0) > 0 && (model.maxTokens ?? 0) > 0)).toBe(true)
expect(server.paths).toEqual([])
})
it('needs no endpoint for a route the catalog describes', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
})
it('says where a route the catalog does not describe must get its models', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway' }))
.rejects.toThrow(/ships no catalog for provider "acme-gateway".*set a baseURL/s)
// A form that cleared the field says the same thing as one that never had it.
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: '' }))
.rejects.toThrow(/set a baseURL/)
// The seam refuses a request naming neither, so the module's own guard for
// that shape is only reachable by calling it directly.
await expect(discoverModels({})).rejects.toThrow(/set a baseURL/)
})
})
describe('draft-provider model discovery', () => {
it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
const server = await listingServer({
body: JSON.stringify({
data: [
{ id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 },
{ id: 'acme-small' },
],
}),
})
const ctx = await harness()
const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' })
expect(models).toEqual([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
expect(server.paths).toEqual(['/v1/models'])
expect(server.headers[0]?.authorization).toBe('Bearer probe-key')
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
})
it('keeps a deployment path instead of resolving it away', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = await harness()
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` })
expect(server.paths).toEqual(['/openai/v1/models'])
})
it('offers no credential when the draft names none', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = await harness()
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })
expect(server.headers[0]?.authorization).toBeUndefined()
})
it('authenticates a configured route the draft cannot supply a key for', async () => {
// What the Models page actually sends after a key is saved: the form holds
// the redacted descriptor, so the draft names the route and the endpoint
// and no credential at all. Interrogating unauthenticated would answer 401
// and read as a wrong key.
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = new Context()
await ctx.plugin(LlmService)
process.env['ACME_GATEWAY_KEY'] = 'stored-key'
touchedEnv.push('ACME_GATEWAY_KEY')
await ctx.plugin(LlmPiAi, {
providers: {
'acme-gateway': {
apiKeyEnv: 'ACME_GATEWAY_KEY',
api: 'openai-completions',
baseURL: server.url,
models: [{ id: 'acme-large' }],
},
},
})
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url })
// A key typed into the form is the one being tested — possibly the
// replacement for the stored one — so it wins.
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url, apiKey: 'typed' })
// A route no profile declares yet is the create case: nothing is stored.
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'not-declared-yet', baseURL: server.url })
expect(server.headers.map(headers => headers.authorization))
.toEqual(['Bearer stored-key', 'Bearer typed', undefined])
})
it('leaves a catalog route\'s credential unresolved, having never reached the network', async () => {
// The catalog answers before any endpoint is asked, so a route whose
// profile names a credential that is not set must still answer rather than
// failing over a key the interrogation never needed.
const ctx = new Context()
await ctx.plugin(LlmService)
Reflect.deleteProperty(process.env, 'ABSENT_FOR_DISCOVERY')
await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'ABSENT_FOR_DISCOVERY' } } })
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
})
it('drops unusable rows rather than failing the whole listing', async () => {
const server = await listingServer({
body: JSON.stringify({
data: [
{ id: 'good' },
{ id: '' },
{ name: 'no id at all' },
null,
{ id: 'good' },
{ id: 'zero-capacity', context_length: 0, max_tokens: -1 },
],
}),
})
const ctx = await harness()
expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
.toEqual([{ id: 'good' }, { id: 'zero-capacity' }])
})
it('points at the credential for a rejected one, and only then', async () => {
const ctx = await harness()
for (const status of [401, 403]) {
const refused = await listingServer({ status, body: '{"error":"nope"}' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' }))
.rejects.toThrow(new RegExp(`answered ${status}; check the API key`))
}
// A server fault is not a credential problem, so it must not send the user
// off to re-check a key that is fine.
const broken = await listingServer({ status: 500, body: '{"error":"boom"}' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' }))
.rejects.toThrow(/answered 500$/)
})
it('reports a reply that is not a model listing', async () => {
const server = await listingServer({ body: '{"models":[]}' })
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
.rejects.toThrow(/no "data" array; enter this provider's models by hand/)
const broken = await listingServer({ body: 'not json at all' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url }))
.rejects.toThrow(/did not answer with JSON/)
})
it('refuses an oversized reply, whether its length is declared or streamed', async () => {
const ctx = await harness()
// Just over the four-megabyte ceiling, as one padded model row.
const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}`
const declared = await listingServer({ body: oversized })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url }))
.rejects.toThrow(/answered with more than 4194304 bytes/)
// A streamed reply declares no length, so the ceiling has to hold on the
// body the harness actually read.
const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url }))
.rejects.toThrow(/answered with more than 4194304 bytes/)
})
it('reports an unreachable endpoint instead of an empty catalog', async () => {
const ctx = await harness()
// Port 9 is the discard service: nothing accepts a connection there.
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' }))
.rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
})
it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])(
'says it cannot interrogate %s rather than guessing a shape',
async (api) => {
// Azure authenticates with an `api-key` header and an `api-version`
// query despite its OpenAI lineage, and Codex uses OAuth; guessing at
// either would report an auth failure as a provider with no models.
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://gateway.example/v1', api }))
.rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
},
)
it('reports cancellation during the body read as an abort, not a raw reason', async () => {
const ctx = await harness()
const controller = new AbortController()
// Chunked, so the headers arrive and the cancellation lands mid-body.
const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 })
const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal })
setTimeout(() => { controller.abort('test cancellation') }, 40)
await expect(probe).rejects.toMatchObject({ code: 'ABORTED' })
})
it('honors caller cancellation', async () => {
const ctx = await harness()
const aborted = AbortSignal.abort('test cancellation')
await expect(ctx.llm.discoverModels('llm-pi-ai', {
baseURL: 'http://127.0.0.1:9/v1',
signal: aborted,
})).rejects.toMatchObject({ code: 'ABORTED' })
})
it('is offered for the namespace, and refuses one it does not serve', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0)
await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' }))
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' }))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
})
it('withdraws the offer when the plugin unloads', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmPiAi, {})
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0)
await fiber.dispose()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' }))
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
})
})

View File

@@ -146,13 +146,16 @@ describe('request-level dynamic profiles', () => {
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => {
it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {} } })
// Schema-valid but catalog-invalid: the resolver rejects it and the
// last good route set keeps serving.
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
// Shape-valid but unserviceable: a route the catalog does not ship and
// that lists no models of its own. The section schema resolves the whole
// profile set, so this is refused where it is written rather than stored
// and then quietly disabling every route in the namespace.
await expect(ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }))
.rejects.toThrow(/resolves no models/)
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})

View File

@@ -1,41 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
const streamSimple = vi.hoisted(() => vi.fn())
// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it
// from there, so the mock must target the same specifier.
vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai/compat')>()
return { ...actual, streamSimple }
})
// A hand-declared route is built by `createProvider` over the protocol table in
// `src/provider.ts`, so the table's lazy api module is the SDK boundary this
// test can observe. A catalog route dispatches through pi-ai's own provider and
// would not see this mock.
vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({
openAICompletionsApi: () => ({ stream: streamSimple, streamSimple }),
}))
import { PiAiAdapter } from '../src/adapter.ts'
import { resolveProfiles } from '../src/config.ts'
afterEach(() => { streamSimple.mockReset() })
/** A hand-declared OpenAI-compatible route with one fully described model. */
function gatewayAdapter(): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles({
'local-gateway': {
apiKey: 'test-key',
api: 'openai-completions',
baseURL: 'http://127.0.0.1:9/v1',
models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }],
},
}),
resolveApiKey: () => Promise.resolve('test-key'),
})
}
async function drain(adapter: PiAiAdapter): Promise<StreamChunk[]> {
const chunks: StreamChunk[] = []
for await (const chunk of adapter.stream({
provider: 'local-gateway',
model: 'local-model',
messages: [],
})) chunks.push(chunk)
return chunks
}
describe('pi-ai SDK retry boundary', () => {
it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => {
const failure = new Error('mock SDK boundary')
streamSimple.mockReturnValue({
async * [Symbol.asyncIterator](): AsyncGenerator<never> {
throw failure
},
})
const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
resolveApiKey: () => Promise.resolve('test-key'),
})
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'openai',
model: 'gpt-4.1',
messages: [],
})) { /* drain */ }
}
streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') })
const chunks = await drain(gatewayAdapter())
await expect(drain()).rejects.toBe(failure)
expect(streamSimple).toHaveBeenCalledOnce()
expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 })
expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0, apiKey: 'test-key' })
// pi-ai reports a setup failure as a terminal in-stream error rather than
// throwing, which the converter turns into the harness error finish.
expect(chunks.at(-1)).toMatchObject({
type: 'finish',
reason: { kind: 'error', failure: { message: 'mock SDK boundary' } },
})
})
it('dispatches a hand-declared route to the endpoint and model its configuration describes', async () => {
streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') })
await drain(gatewayAdapter())
expect(streamSimple.mock.calls[0]?.[0]).toMatchObject({
id: 'local-model',
provider: 'local-gateway',
api: 'openai-completions',
baseUrl: 'http://127.0.0.1:9/v1',
contextWindow: 8192,
maxTokens: 1024,
})
})
})

View File

@@ -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/README.md
README.md: 5d74ed647f4de3c8ed65554dff736eb8aec9eef9
README.zh.md: a362ba8b825238325ce70238c5b2f3725f0d8495
README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd
README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972

View File

@@ -12,8 +12,11 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber.
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. The handle also carries `replace(entries)`: the candidate set is validated in full before anything moves, so an entry another registration already declares leaves the current set intact, and an empty array is legal there. A plugin whose declared set follows its configuration must use `replace` rather than disposing and re-registering — the latter strands the directory empty whenever the new set is refused.
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant.
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber.
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works.
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` Ask one endpoint which models it advertises.
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved.
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
@@ -23,6 +26,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy.
Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out.

View File

@@ -12,8 +12,11 @@
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose资源释放。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供「询问提供方端点」的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace让界面只在可用之处提供该动作。
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` 询问某个端点它公布了哪些模型。
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份以及可用上下文、输出默认值和推理reasoning元数据异步适配器可选地支持取消。
@@ -23,6 +26,8 @@
`LlmService` 将最终适配器选择、同步 dispatch、iterator 构造与迭代中的失败规范化为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出因为它们属于插件或消费方失败而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略完全由 middleware 处理的路由没有服务策略。
询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials回复是界面可供用户采纳的候选元数据而不是已注册的 catalog。`LlmDiscoveredModel``id` 外每个字段都是可选的,因为大多数提供方列表只公布 id采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER``INVALID_CATALOG` 失败。
每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。

View File

@@ -10,8 +10,10 @@ import { Context, Service } from 'cordis'
import type {
GenerateOptions,
LlmConfigurableProvider,
LlmDiscoveredModel,
LlmFailure,
LlmModelContext,
LlmModelDiscoveryRequest,
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
@@ -225,6 +227,27 @@ export interface AdapterRegistrationHandle {
replace(providers: string[]): void
}
/**
* A live configurable-provider registration, disposable and atomically
* replaceable — the directory counterpart of {@link AdapterRegistrationHandle}.
*/
export interface DirectoryRegistrationHandle {
/** Withdraw every entry this registration currently holds. */
(): void
/**
* Replace this registration's entries with `entries`. The candidate set is
* validated in full first — an entry another registration already declares,
* a duplicate within the set, or invalid metadata throws and leaves the
* current entries untouched — and the swap is one synchronous section, so no
* reader observes a gap. An empty array is legal here, unlike an empty
* initial registration.
*
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
* has been disposed.
*/
replace(entries: readonly LlmConfigurableProvider[]): void
}
/**
* The abstract `llm` service: an adapter registry plus a streaming model-call
* surface, interceptable via the `llm/stream` waterfall.
@@ -232,6 +255,10 @@ export interface AdapterRegistrationHandle {
export class LlmService extends Service {
private adapters = new Map<string, AdapterRegistration>()
private directory = new Map<string, LlmConfigurableProvider>()
private discoveries = new Map<
string,
(request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
>()
constructor(ctx: Context) {
super(ctx, 'llm')
@@ -370,34 +397,61 @@ export class LlmService extends Service {
* entry, or a provider already declared by any registration throws
* `LlmError` without registering the rest. Disposed with the fiber.
* @param entries - every configurable provider this plugin owns.
* @returns the disposer that withdraws all of them.
* @returns a handle that withdraws all of them, and can atomically replace them.
*/
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void {
const dispose = this.ctx.effect(function* (this: LlmService) {
if (entries.length === 0) {
throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY')
}
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle {
let held: LlmConfigurableProvider[] = []
let disposed = false
/**
* Validate a candidate set in full against everything this registration
* does not already hold, then publish it. Nothing is written until the
* whole set passes, so a refused candidate leaves the current entries in
* place — the property that makes `replace` a swap rather than a
* delete-then-add that can strand the directory empty.
*/
const commit = (candidates: readonly LlmConfigurableProvider[]): void => {
const detached: LlmConfigurableProvider[] = []
for (const entry of entries) {
const own = new Set(held.map(entry => entry.provider))
for (const entry of candidates) {
if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) {
throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY')
}
if (entry.settingsPath.some(segment => segment.length === 0)) {
throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY')
}
if (this.directory.has(entry.provider) || detached.some(seen => seen.provider === entry.provider)) {
if ((this.directory.has(entry.provider) && !own.has(entry.provider))
|| detached.some(seen => seen.provider === entry.provider)) {
throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY')
}
detached.push({ ...entry, settingsPath: [...entry.settingsPath] })
}
for (const entry of held) this.directory.delete(entry.provider)
for (const entry of detached) this.directory.set(entry.provider, entry)
held = detached
this.emitAdaptersUpdated()
}
const dispose = this.ctx.effect(function* (this: LlmService) {
if (entries.length === 0) {
throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY')
}
commit(entries)
yield () => {
for (const entry of detached) this.directory.delete(entry.provider)
disposed = true
for (const entry of held) this.directory.delete(entry.provider)
held = []
this.emitAdaptersUpdated()
}
}.bind(this), 'llm.registerConfigurableProviders()')
return () => void dispose()
const handle = ((): void => void dispose()) as DirectoryRegistrationHandle
handle.replace = (next: readonly LlmConfigurableProvider[]): void => {
if (disposed) {
throw new LlmError('this configurable-provider registration was disposed', 'REGISTRATION_DISPOSED')
}
commit(next)
}
return handle
}
/**
@@ -408,6 +462,73 @@ export class LlmService extends Service {
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
}
/**
* Offer to interrogate provider endpoints on behalf of the settings
* namespace this plugin owns. The namespace is the key because that is what
* a configuration surface already holds from the configurable-provider
* directory, and because a provider being *added* has no route to name yet.
* Disposed with the fiber.
* @param settingsNs - the namespace whose profiles this discovery serves.
* @param discover - interrogates one endpoint; must honor `request.signal`.
* @returns the disposer that withdraws the offer.
*/
registerModelDiscovery(
settingsNs: string,
discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
): () => void {
const dispose = this.ctx.effect(function* (this: LlmService) {
if (settingsNs.length === 0) {
throw new LlmError('model discovery needs a non-empty settings namespace', 'INVALID_DISCOVERY')
}
if (this.discoveries.has(settingsNs)) {
throw new LlmError(`model discovery for "${settingsNs}" is already registered`, 'DUPLICATE_DISCOVERY')
}
this.discoveries.set(settingsNs, discover)
yield () => {
this.discoveries.delete(settingsNs)
}
}.bind(this), 'llm.registerModelDiscovery()')
return () => void dispose()
}
/**
* Interrogate one provider endpoint for the models it advertises. The
* request describes a draft, not a stored route, so nothing here reads or
* writes settings or credentials — the caller owns both, and the reply is
* candidate metadata a surface may offer for adoption.
* @param settingsNs - namespace whose registered discovery serves this draft.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @returns the advertised models, deduplicated in endpoint order.
*/
async discoverModels(
settingsNs: string,
request: LlmModelDiscoveryRequest,
): Promise<LlmDiscoveredModel[]> {
const discover = this.discoveries.get(settingsNs)
if (discover === undefined) {
throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY')
}
// One of the two identifies what to describe: a route the adapter knows, or
// an endpoint to ask. Neither leaves nothing to answer about.
if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY')
}
const discovered = await discover(request)
const seen = new Set<string>()
const models: LlmDiscoveredModel[] = []
for (const model of discovered) {
if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id)) continue
seen.add(model.id)
models.push({
id: model.id,
...model.name === undefined ? {} : { name: model.name },
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
})
}
return models
}
/**
* Resolve the retry policy captured when one provider route was registered.
* @param provider - registered provider route to inspect.

View File

@@ -29,17 +29,99 @@ export interface ToolMessageSource {
callId: CallId
}
/**
* What SHAPE of information a producer-supplied context carries, declared by
* the producer beside its provenance.
*
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
* of thing it is*, and the two axes are deliberately independent — several
* producers share one form (three snapshot producers today), and one producer
* may emit more than one form over a session.
*
* The vocabulary is SEMANTIC, never visual: a value states that the content is
* a file's instructions or a catalog of available items, and a consumer decides
* what that looks like. Colors, icons, ordering, and collapse defaults are the
* consumer's business and must not enter this union. It grows one value at a
* time as producers gain the structured fields their form needs; an absent or
* unknown value is the documented default, presented as opaque content.
*/
export type ContextForm =
/** Instructions read out of workspace files the model is expected to follow. */
| 'instructions'
/** A catalog of items available in this session, republished as it changes. */
| 'catalog'
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
| 'snapshot'
/** A one-off account of something that just happened; it supersedes nothing. */
| 'notice'
/** A message another agent addressed to this one. */
| 'relay'
/** Material lifted out of another session's log, possibly reduced on the way in. */
| 'recall'
/** One named contribution to a `snapshot`-form context, in assembly order. */
export interface ContextSnapshotSection {
/** The contributing subsystem's name. */
readonly name: string
/** That contribution's model-facing text, exactly as assembled. */
readonly text: string
}
/**
* Producer-declared {@link ContextForm} and the fields that form requires,
* mixed into the source shapes that carry one.
*
* Discriminated by `form` so a producer cannot declare a shape without the
* facts that shape is presented from: a `notice` must record its one-line
* account, a `snapshot` its sections. Omitting `form` stays valid — an
* undeclared context is the documented default.
*/
export type ContextFormed =
| { readonly form?: never }
| { readonly form: 'instructions' }
| { readonly form: 'catalog' }
| {
readonly form: 'snapshot'
/** The named contributions this snapshot assembled, in order. */
readonly sections: readonly ContextSnapshotSection[]
}
| {
readonly form: 'notice'
/** One-line account of what happened, shown without expanding the row. */
readonly summary: string
}
| { readonly form: 'relay' }
| { readonly form: 'recall' }
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
plugin: { kind: 'plugin'; plugin: string } & ContextFormed
model: ModelMessageSource
tool: ToolMessageSource
}
/**
* Bound for a `notice` summary. The account rides a collapsed transcript row
* and is committed to the durable log, while its inputs — task labels, goal
* objectives, tool arguments — are caller text with no length of their own.
*/
export const CONTEXT_SUMMARY_MAX_CHARS = 120
/**
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
* @param summary - the producer's one-line account, of any length.
* @returns the account, ellipsized when it exceeds the bound.
*/
export function boundContextSummary(summary: string): string {
return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
? summary
: `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}`
}
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]

View File

@@ -139,6 +139,49 @@ export interface LlmConfigurableProvider {
settingsPath: readonly string[]
}
/**
* One interrogation of a provider endpoint that configuration has not stored
* yet. Configuration surfaces send the draft a user is still editing, so the
* request carries the endpoint and credential directly instead of naming a
* route: a provider being added has no route to name.
*/
export interface LlmModelDiscoveryRequest {
/**
* Route the draft is editing, when it edits an existing one. A route whose
* adapter already knows its models answers from that knowledge instead of
* asking the endpoint — the adapter's own registry is the better answer, and
* it costs no network call.
*/
provider?: string
/**
* Endpoint to interrogate. Optional because a route the adapter already
* describes needs none; a route it does not must supply one.
*/
baseURL?: string
/** Wire protocol the endpoint speaks, when the draft names one. */
api?: string
/** Credential for this interrogation alone; the harness never stores it. */
apiKey?: string
/** Caller cancellation; implementations must settle promptly after it aborts. */
signal?: AbortSignal
}
/**
* One model an endpoint reports about itself. Every field but the id is
* optional because most provider listings disclose an id and nothing else;
* a surface adopting one of these still owes the capacities its adapter needs.
*/
export interface LlmDiscoveredModel {
/** Model id the endpoint accepts. */
id: string
/** Human-readable name when the endpoint supplies one. */
name?: string
/** Maximum combined request and response context, when disclosed. */
contextWindow?: number
/** Maximum output tokens, when disclosed. */
maxTokens?: number
}
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
export interface LlmModelInfo {
/** Provider route that owns this model entry. */

View File

@@ -170,6 +170,32 @@ describe('configurable-provider directory', () => {
expect(ctx.llm.listConfigurableProviders()).toEqual([])
})
it('replaces its entries atomically, keeping the old set when a candidate collides', async () => {
const ctx = await setup()
const handle = ctx.llm.registerConfigurableProviders([entry(), entry({ provider: 'second' })])
ctx.llm.registerConfigurableProviders([entry({ provider: 'owned-elsewhere' })])
// A candidate another registration already declares refuses the whole swap.
expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]) }).toThrow(/already declared/)
expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort())
.toEqual(['owned-elsewhere', 'second', entry().provider].sort())
// Its own entries are not "already declared" against itself, so a swap that
// keeps one and drops another lands whole.
handle.replace([entry({ displayName: 'Renamed' })])
expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort())
.toEqual(['owned-elsewhere', entry().provider].sort())
expect(ctx.llm.listConfigurableProviders().find(view => view.provider === entry().provider)?.displayName)
.toBe('Renamed')
// An empty replace is legal, unlike an empty initial registration.
handle.replace([])
expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere'])
handle()
expect(() =>{ handle.replace([entry()]) }).toThrow(/was disposed/)
})
it('rejects duplicates within one registration and across registrations', async () => {
const ctx = await setup()
expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/)
@@ -179,3 +205,64 @@ describe('configurable-provider directory', () => {
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
})
})
describe('model discovery registry', () => {
it('offers one interrogation per settings namespace and disposes with its fiber', async () => {
const ctx = await setup()
const discover = vi.fn(() => Promise.resolve([{ id: 'from-endpoint' }]))
const dispose = ctx.llm.registerModelDiscovery('llm-example', discover)
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
.resolves.toEqual([{ id: 'from-endpoint' }])
expect(discover).toHaveBeenCalledWith({ baseURL: 'https://gateway.example/v1' })
// Disposal is observed through the offer itself, which is the only thing
// the registration ever produced.
dispose()
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
.rejects.toThrow(/no model discovery is registered/)
})
it('rejects an unnamed namespace and a second registration of the same one', async () => {
const ctx = await setup()
const discover = (): Promise<never[]> => Promise.resolve([])
expect(() => ctx.llm.registerModelDiscovery('', discover)).toThrow(/non-empty settings namespace/)
ctx.llm.registerModelDiscovery('llm-example', discover)
expect(() => ctx.llm.registerModelDiscovery('llm-example', discover)).toThrow(/already registered/)
// The refused second registration left the first one serving.
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
.resolves.toEqual([])
})
it('normalizes what an interrogation returns without inventing capacities', async () => {
const ctx = await setup()
ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([
{ id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
{ id: '' },
{ id: 'keep' },
{ id: 'bare' },
] as never))
expect(await ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })).toEqual([
{ id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
{ id: 'bare' },
])
})
it('refuses a namespace nothing serves and a draft with no endpoint', async () => {
const ctx = await setup()
ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([]))
await expect(ctx.llm.discoverModels('llm-absent', { baseURL: 'https://gateway.example/v1' }))
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' }))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-example', { provider: '', baseURL: '' }))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-example', {}))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
// Naming a route alone is enough: the adapter may know it without an endpoint.
await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([])
})
})