Merge remote-tracking branch 'github/master' into feat/agent-event-payload
# Conflicts: # docs/core-data-structures/core.i18n.yaml
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-version,Codex 走 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。
|
||||
|
||||
@@ -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 },
|
||||
|
||||
223
packages/llm/llm-pi-ai/src/catalog.ts
Normal file
223
packages/llm/llm-pi-ai/src/catalog.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
262
packages/llm/llm-pi-ai/src/discovery.ts
Normal file
262
packages/llm/llm-pi-ai/src/discovery.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
191
packages/llm/llm-pi-ai/src/provider.ts
Normal file
191
packages/llm/llm-pi-ai/src/provider.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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/)
|
||||
|
||||
576
packages/llm/llm-pi-ai/tests/catalog.spec.ts
Normal file
576
packages/llm/llm-pi-ai/tests/catalog.spec.ts
Normal 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 provider’s own auth when the route repoints its protocol', () => {
|
||||
// Which environment a provider reads is a property of the provider, not of
|
||||
// the wire format its models speak: naming an api must not cost a profile
|
||||
// its provider-native discovery.
|
||||
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
||||
expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
|
||||
})
|
||||
|
||||
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
|
||||
// pi-ai honours a request's `apiKey` override only when the provider
|
||||
// declares an api-key method. `openai-codex` ships OAuth alone, so without
|
||||
// the harness method beside it the route refuses its own configured key as
|
||||
// `Provider is not configured` before any request goes out.
|
||||
const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } })
|
||||
const provider = resolved.get('openai-codex')?.piProvider
|
||||
expect(provider?.auth.oauth).toBeDefined()
|
||||
const models = createModels()
|
||||
models.setProvider(provider as Provider)
|
||||
const model = provider?.getModels()[0] as Model<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)
|
||||
})
|
||||
})
|
||||
313
packages/llm/llm-pi-ai/tests/discovery.spec.ts
Normal file
313
packages/llm/llm-pi-ai/tests/discovery.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 码的故障会在扇出后重新抛出。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user