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:
Yichen Jiang
2026-08-04 13:32:56 +08:00
parent 4c80cab108
commit f376ee23d1
22 changed files with 368 additions and 108 deletions
+33 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
/**