fix(llm): size unknown models and refuse a section that cannot be served
Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
This commit is contained in:
@@ -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<string, string>
|
||||
/** 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`
|
||||
|
||||
|
||||
@@ -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/*`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T> {
|
||||
base?: Partial<T>
|
||||
/** 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
|
||||
|
||||
@@ -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<T> {
|
||||
base?: Partial<T>
|
||||
/** 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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -2639,7 +2639,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SettingsRegisterOptions',
|
||||
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n}',
|
||||
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsScope',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,因此适配器会拒绝该字段。
|
||||
|
||||
@@ -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<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()
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>
|
||||
/** Provider-neutral pi-ai reasoning level. */
|
||||
@@ -122,7 +140,6 @@ const modelProfile: z<PiAiModelProfile> = 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<Config> = 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, {
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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<Record<string, () => 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface SettingsRegisterOptions<T> {
|
||||
base?: Partial<T>
|
||||
/** 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<unknown>
|
||||
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<unknown>,
|
||||
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<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
|
||||
private resolve<T>(
|
||||
schema: z<T>,
|
||||
base: unknown,
|
||||
section: Record<string, unknown> | 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<T> {
|
||||
* 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<T>(
|
||||
hooks: SettingsSectionHooks<T>,
|
||||
): 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
|
||||
|
||||
@@ -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<string, unknown>): 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)
|
||||
|
||||
Reference in New Issue
Block a user