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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user