Bläddra i källkod

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.
Yichen Jiang 2 månader sedan
förälder
incheckning
f376ee23d1

+ 16 - 5
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<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`
 

+ 2 - 2
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/*`
 

+ 1 - 1
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`
 

+ 2 - 2
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

+ 20 - 1
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<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

+ 20 - 1
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<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

+ 2 - 2
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) |

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -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 - 2
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

+ 19 - 5
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`.
+
+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`, `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.
+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.

+ 19 - 5
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` 中。
+
+**没有**这份元数据的模型——每一个手工声明的模型,以及 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`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
+受支持的 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,因此适配器会拒绝该字段。

+ 33 - 10
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<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),
       }
     })
   }

+ 17 - 15
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,

+ 38 - 1
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<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, {

+ 20 - 16
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<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
     },

+ 16 - 17
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<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)
 }
 
 /**

+ 5 - 6
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 () => {

+ 55 - 4
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',

+ 7 - 4
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'])
   })
 

+ 2 - 2
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 () => {

+ 46 - 6
packages/settings/settings/src/index.ts

@@ -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

+ 25 - 0
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<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)