diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 65ba351476..b6d9d12189 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -718,6 +718,18 @@ export interface PiAiProviderProfile { * 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 /** Provider-neutral pi-ai reasoning level. */ @@ -748,18 +760,17 @@ export interface PiAiModelProfile { contextWindow?: number /** * Maximum output tokens. Configuring one also makes it this model's - * per-request default; the value inherited from the installed catalog is the - * model's capability and never becomes a request default on its own. + * 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 - /** Whether the model exposes reasoning; defaults to the catalog capability. */ - reasoning?: boolean } ``` Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:104`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index b1578717fd..423f5bc9cb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -726,7 +726,7 @@ One registered namespace's RAW user section changed, whether or not the resolved Types: [SettingsNamespace](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:150`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:167`](../../packages/settings/settings/src/index.ts) ### `settings/updated` — emit @@ -753,7 +753,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:137`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:154`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 55173f08d2..d2b679657f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1813,7 +1813,7 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsPathOp](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:365`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:384`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index 50c20a0aab..bc8a9893b8 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/settings.md -settings.md: 1cabfae5d8dc72a9cd79341d250ee79820693872 -settings.zh.md: d63a1384646fa38199e7d65e9f0504f0440597be +settings.md: 08f99b5e65ac4a57cdfff3323c0d9148d379bed8 +settings.zh.md: 08903810b44c293944950a8f5ae2710f45678d60 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index 1cabfae5d8..08f99b5e65 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## Registration -Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer and the owner's effect timing. +Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express. ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -26,9 +26,28 @@ interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } ``` +`validate` runs after the schema admits a value, so it sees defaults and the composition base exactly as the owner will. `dsh-llm-pi-ai` uses it to refuse a provider profile it could not serve at the write that produced it, rather than storing one that would disable every route in its namespace. + `applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change. ```ts type-equiv diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index d63a138464..08903810b4 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## 注册 -注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层与 owner 的生效时机。 +注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。 ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -26,9 +26,28 @@ interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } ``` +`validate` 在 schema 接纳该值之后运行,因此它看到的默认值与组合 base 与 owner 将看到的完全一致。`dsh-llm-pi-ai` 用它在写入处拒绝自己无法服务的提供方 profile,而不是先存下来、再让该 namespace 下每条路由失效。 + `applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。 ```ts type-equiv diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cd53931df3..7fe303e0ef 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,8 +38,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:167`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:154`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 298a493730..7feea98742 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2639,7 +2639,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsRegisterOptions', - declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, { name: 'SettingsScope', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 5e87c87fb4..89cecdfdec 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: dde6ce989a0fd87bf2dc60ce0d85deb1856d92d5 -README.zh.md: bb87ada7b5cf883b458c8cf9ace66bbd64fa154f +README.md: 884eadbde73f17ffd50994e09c975cca561d81ed +README.zh.md: af8a620eeccb2b971c4550bdcd7c8af93d5d4f90 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e3d8f04b9b..a8d425a4bd 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -53,9 +53,11 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array ## 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`, `maxTokens`, and `reasoning`; pricing and input modalities have no harness consumer and ride the installed entry or are absent, while reasoning-level spellings and OpenAI-compatibility quirks have no configuration surface at all because restating them cannot be validated. +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. -Resolution fails loud, naming the offending route and model, when a route cannot be served: a model the installed catalog does not describe needs an explicit `contextWindow` and `maxTokens`, and a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. `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. +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. @@ -69,12 +71,24 @@ Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `ap 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`, `displayName`, `api`, `baseURL`, `models`, `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. + +Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. + +Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. + ## Provider/model routing and replay Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. @@ -137,7 +151,7 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). -- **Model discovery is configuration, not a provider query** — the route's catalog is whatever `settings.yaml` says; nothing fetches a provider's `/models` endpoint, so a model list is only as current as its last edit. A one-shot discovery action that offers a provider's live list for the user to adopt belongs to the configuration surface and is deferred with it. +- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Endpoint interrogation is an explicit action a configuration surface takes over a draft; nothing re-runs it, and adopting its result is a settings write like any other. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. - **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 98660943b7..00a20c5e60 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -53,9 +53,11 @@ ## Catalog 解析 -profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens` 与 `reasoning`;定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席,而思考级别的协议拼写与 OpenAI 兼容性怪癖则完全没有配置面,因为重述它们无法被校验。 +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 -解析会失败得响亮,并点名出问题的路由与模型:已安装 catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`,catalog 未提供的路由则需要 `api`、`baseURL` 和非空的 `models` 列表。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 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 之间迁移的方式。 @@ -69,12 +71,24 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器通过 `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`、`displayName`、`api`、`baseURL`、`models`、`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 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 + +询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 + +多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 + ## 提供方/模型路由与回放 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 @@ -137,7 +151,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **模型发现属于配置,不是提供方查询**:路由的 catalog 就是 `settings.yaml` 所写的内容;没有任何环节会去拉取提供方的 `/models` 端点,因此模型列表的新鲜度只到最近一次编辑为止。把提供方实时列表呈给用户采纳的一次性发现动作属于配置界面,与之一并暂缓。 +- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。端点询问是配置界面针对草稿主动发起的动作;没有任何环节会重跑它,采纳其结果与任何其他 settings 写入无异。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3a70d0f4af..2cdb7e6657 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -107,6 +107,38 @@ function resolveReasoningLevel( ) } +/** + * Selectable reasoning efforts for one model, or nothing at all. + * + * A model the installed catalog does not describe carries no reasoning + * metadata, and pi-ai reports that as 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, + defaultLevel: ModelThinkingLevel | undefined, +): Pick | Record { + 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> | undefined): Record { const attribution = attributionHeaders() @@ -188,7 +220,6 @@ export class PiAiAdapter extends LlmAdapter { const snapshot = this.current() const profile = this.profileOf(snapshot, provider) const resolvedModel = this.modelOf(snapshot, provider, model) - const levels = getSupportedThinkingLevels(resolvedModel) 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. @@ -199,15 +230,7 @@ export class PiAiAdapter extends LlmAdapter { name: resolvedModel.name, context: { contextWindow: resolvedModel.contextWindow }, ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, - reasoning: { - efforts: levels.map(level => ({ - id: ReasoningEffortId(level), - name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, - })), - ...defaultLevel === undefined - ? {} - : { defaultEffort: ReasoningEffortId(defaultLevel) }, - }, + ...reasoningInfo(resolvedModel, defaultLevel), } }) } diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index a68c5fd9e8..2ac66b5a5e 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -81,12 +81,11 @@ export interface PiAiModelProfile { contextWindow?: number /** * Maximum output tokens. Configuring one also makes it this model's - * per-request default; the value inherited from the installed catalog is the - * model's capability and never becomes a request default on its own. + * 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 - /** Whether the model exposes reasoning; defaults to the catalog capability. */ - reasoning?: boolean } /** The route-level facts model materialization reads. */ @@ -99,6 +98,10 @@ export interface RouteCatalogRequest { 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. */ @@ -177,19 +180,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { if (baseUrl === undefined) { invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`) } - const contextWindow = entry.contextWindow ?? base?.contextWindow - if (contextWindow === undefined) { - invalid(provider, `model "${entry.id}" needs a contextWindow; without it the harness cannot detect overflow` - + ' or size compaction') - } + // 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 - if (maxTokens === undefined) { - invalid(provider, `model "${entry.id}" needs a maxTokens; it is the output cap materialized into requests` - + ' that omit one') - } + 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`) } @@ -202,7 +201,10 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - reasoning: entry.reasoning ?? base?.reasoning ?? false, + // 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, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 01a63efb4a..0406a6906b 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -28,6 +28,12 @@ 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. */ @@ -52,6 +58,18 @@ export interface PiAiProviderProfile { * 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 /** Provider-neutral pi-ai reasoning level. */ @@ -122,7 +140,6 @@ const modelProfile: z = z.object({ name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), - reasoning: z.boolean(), }) const profile = z.object({ @@ -132,6 +149,8 @@ const profile = z.object({ 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, @@ -148,6 +167,22 @@ export const Config: z = z.object({ providers: z.dict(profile).default({}), }) +/** + * 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 & { @@ -211,6 +246,8 @@ export function resolveProfiles( ...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, { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index bc0e77658c..96fd2f1d8a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -48,7 +48,7 @@ import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigu import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { catalogProviderIds } from './catalog.ts' -import { Config, resolveProfiles } from './config.ts' +import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' @@ -98,24 +98,24 @@ export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config let lastRaw: Config | undefined let lastGood: ReadonlyMap | undefined + /** + * The resolved profiles for the current configuration, memoized by the raw + * snapshot's identity — which is also what makes the adapter's own snapshot + * 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 => { 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 - } + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next } profiles() @@ -201,6 +201,10 @@ 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 }, diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index 07ec533919..d69fd539e6 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -22,11 +22,8 @@ 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 { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' -import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy' import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' -import { piMessagesApi } from '@earendil-works/pi-ai/api/pi-messages.lazy' import { catalogProvider } from './catalog.ts' /** @@ -35,32 +32,34 @@ import { catalogProvider } from './catalog.ts' * factory uses, so a hand-declared route reaches exactly the implementation a * catalog route would. * - * The table 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 + * 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. Catalog routes still reach those protocols through their own - * provider; only an explicit override is refused. + * 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 ProviderStreams>> = { - 'anthropic-messages': anthropicMessagesApi, - 'google-generative-ai': googleGenerativeAIApi, - 'mistral-conversations': mistralConversationsApi, 'openai-completions': openAICompletionsApi, 'openai-responses': openAIResponsesApi, - 'pi-messages': piMessagesApi, + 'anthropic-messages': anthropicMessagesApi, } /** - * Every wire protocol a configured route may name, sorted for stable - * diagnostics and configuration surfaces. + * 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).sort() + return Object.keys(PROTOCOLS) } /** diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 8481420661..7269aa6f61 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -335,12 +335,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 () => { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index b2684c1997..9129d213e2 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -99,6 +99,26 @@ describe('hand-declared providers', () => { }) }) + 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`)) @@ -111,13 +131,44 @@ describe('hand-declared providers', () => { }) }) - it('rejects a model whose capacity the catalog cannot supply', () => { + 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: 'acme-large', maxTokens: 1 })).toThrow(/needs a contextWindow/) - expect(declare({ id: 'acme-large', contextWindow: 1 })).toThrow(/needs a maxTokens/) - expect(declare({ id: '', contextWindow: 1, maxTokens: 1 })).toThrow(/empty id/) + expect(declare({ id: '' })).toThrow(/empty id/) expect(() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..c5eb230419 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -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']) }) diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 3e54db480f..a680447ec7 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -176,7 +176,7 @@ describe('configurable-provider directory', () => { 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(() =>{ 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()) @@ -193,7 +193,7 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere']) handle() - expect(() =>{ handle.replace([entry()]); }).toThrow(/was disposed/) + expect(() =>{ handle.replace([entry()]) }).toThrow(/was disposed/) }) it('rejects duplicates within one registration and across registrations', async () => { diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index f51e08cb03..a617a73d7c 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -44,6 +44,23 @@ export interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** One registered namespace as surfaced to configuration UIs. */ @@ -343,6 +360,8 @@ interface SettingsRegistration { schema: z base: unknown applies: SettingsApplies + /** Owner-supplied check for constraints the schema cannot express. */ + validate?: (value: unknown) => void resolved: unknown /** * Monotonic counter over this namespace's RAW user section — bumped by any @@ -456,7 +475,10 @@ export abstract class Settings extends Service { schema: schema as z, base: options?.base, applies: options?.applies ?? 'live', - resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + ...options?.validate === undefined + ? {} + : { validate: options.validate as (value: unknown) => void }, + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)), revision: 0, watchers: new Set(), } @@ -642,7 +664,7 @@ export abstract class Settings extends Service { : mode === 'replace' ? snapshot : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current) - const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate)) await this.persist(ns, section) // The write reached storage either way; the cache must say so. Commit // only when this registration is still the namespace owner — a fiber @@ -684,7 +706,7 @@ export abstract class Settings extends Service { for (const registration of this.registrations.values()) { let next: unknown try { - next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate)) } catch (error) { this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) this.ctx.logger.warn(error) @@ -706,10 +728,19 @@ export abstract class Settings extends Service { } /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ - private resolve(schema: z, base: unknown, section: Record | undefined): T { + private resolve( + schema: z, + base: unknown, + section: Record | undefined, + validate?: (value: T) => void, + ): T { // The merged candidate is untyped by construction; the schema call is the // runtime validation that admits it into T. - return schema(mergeLayers(base, section) as never) + const value = schema(mergeLayers(base, section) as never) + // The owner's own check runs on the admitted value, so it sees defaults + // and the composition base exactly as the owner will. + validate?.(value) + return value } /** @@ -842,6 +873,12 @@ export interface SettingsSectionHooks { * memoized resolutions — after an attach, a detach, or a committed change. */ onChange(): void + /** + * Reject a resolved section this consumer could not act on, for constraints + * its schema cannot express. See {@link SettingsRegisterOptions.validate}. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** @@ -865,7 +902,10 @@ export function installSettingsSection( hooks: SettingsSectionHooks, ): void { ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(ns, schema, { base: entry }) + const scope = sctx.settings.register(ns, schema, { + base: entry, + ...hooks.validate === undefined ? {} : { validate: hooks.validate }, + }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { // This disposer runs for two different reasons. A settings provider diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index dd3d5e1bc3..294c933a89 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -95,6 +95,31 @@ describe('registration', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) }) + it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => { + const { ctx } = await boot() + const ns = settingsNamespace('ui-theme') + // A constraint the schema cannot express: this owner cannot serve a size + // it considers unreadable, whatever the schema admits. + const scope = ctx.settings.register(ns, ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + }) + const before = scope.get() + + await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/) + expect(scope.get()).toEqual(before) + + // An externally edited document must not strand the owner: the namespace + // keeps its last good value, exactly as a schema failure would. + ;(ctx.settings as unknown as { publish(doc: Record): void }) + .publish({ 'ui-theme': { fontSize: 4 } }) + expect(scope.get()).toEqual(before) + + await ctx.settings.update(ns, { fontSize: 18 }) + expect(scope.get()).toMatchObject({ fontSize: 18 }) + }) + it('rejects a duplicate namespace loud', async () => { const { ctx } = await boot() ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)