/** * Configuration schema and provider-profile validation for the pi-ai adapter. * Profiles are a dict keyed by provider route, so the composition base and a * user-settings layer merge per provider and the route set is structural. * * A route key is not required to name an installed pi-ai provider. When it does, * that provider's endpoint, protocol, display name, and model catalog are the * profile's defaults and the profile overrides them field by field; when it does * not, the profile is the whole provider declaration. Resolution therefore ends * in a built pi-ai `Provider` per route: everything a request needs is decided * once, while the configuration key that made a route unserviceable can still be * named in the failure. * * @module dsh-llm-pi-ai/config */ import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' 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 { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat, } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Name shown by configuration surfaces; defaults to the route key. */ displayName?: string /** * Wire protocol every model on this route speaks. Omission keeps each * installed catalog model's own protocol, which is why a catalog route needs * no protocol at all; a route the catalog does not ship must name one. */ api?: string /** Endpoint for this route's models; defaults to the installed catalog's endpoint. */ baseURL?: string /** * This route's model catalog. Omission serves the installed catalog for the * route unchanged; an explicit list replaces it, each entry defaulting its * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] /** * Installed-catalog customizations by model id: each entry reshapes that * one model with the same fields a {@link models} entry takes, while the * rest of the catalog keeps serving untouched. Only meaningful on a catalog * route with no `models` list — `models` already replaces the catalog, so * an override beside it, on a route the catalog does not ship, or naming a * model the catalog does not describe is refused rather than skipped. */ modelOverrides?: Record /** * Reasoning-dispatch switches for every `openai-completions` model on this * route; each model's own `compat` overrides per field. What neither sets * keeps the installed catalog entry's value, then pi-ai's baseURL-derived * detection. */ compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor * the installed catalog sizes (default 32,768). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets /** Prompt-cache retention preference. */ cacheRetention?: CacheRetention /** Streaming transport preference. */ transport?: Transport /** HTTP/provider SDK timeout in milliseconds. */ timeoutMs?: number /** WebSocket connection timeout in milliseconds. */ websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number /** Provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } /** Validated profile with its route stamped and every adapter-owned default resolved. */ export interface ResolvedPiAiProviderProfile extends Omit { /** Harness route key and the `Models` collection key (the configuration dict key). */ provider: string /** Resolved display name for selectors and configuration surfaces. */ displayName: string /** Validated credential reference, when one is configured. */ apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy /** * The pi-ai provider this route registers, built from the resolved models. * Construction happens here so an unserviceable protocol or an underspecified * model fails with the rest of resolution, leaving the last good route set * serving requests. */ piProvider: Provider /** * Per-request output caps this profile explicitly configured, by model id. * The seam materializes one only into a request that names no cap of its * own, so a catalog capability must not appear here. */ configuredMaxTokens: ReadonlyMap } /** Plugin configuration: the provider routes this instance owns. */ export interface Config { /** * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is * the dormant settings-driven posture: the adapter mounts with no routes * and registers them the moment a settings section supplies profiles. */ providers?: Record } const thinkingBudgets = z.object({ minimal: z.number(), low: z.number(), medium: z.number(), high: z.number(), }) const compatProfile: z = z.object({ thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), supportsReasoningEffort: z.boolean(), }) /** * Keys are the offered levels, values their wire spellings. A valueless key * (`off:`) survives validation because schemastery passes nullable data * through before any member schema runs — `z.const(null)` only controls the * error for non-null wrong values and what a configuration UI renders. * Only resolution decides which levels may leave the value empty, so the * diagnostic can name the route and model. The assertion narrows * schemastery's `Dict`, which types every literal key as required; dict * validation checks only present keys, so the runtime value is a partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS), ) as unknown as z /** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */ const modelFields = { name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), // The union, not a bare dict: schemastery materializes an absent dict as // `{}`, and absent must stay distinguishable — it means "inherit the // installed catalog's capability", while `false` disables reasoning. reasoningEfforts: z.union([z.const(false), reasoningEfforts]), compat: compatProfile, } const modelProfile: z = z.object({ id: z.string().required(), ...modelFields, }) /** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ const modelOverride: z = z.object(modelFields) const profile = z.object({ apiKeyEnv: z.string().role('credential-ref'), displayName: z.string(), api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), modelOverrides: z.dict(modelOverride), compat: compatProfile, 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(THINKING_LEVELS), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), retryPolicy: RetryPolicySchema, }) /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ providers: z.dict(profile).default({}), }) /** * Reject a section this adapter could not serve. Registered as the settings * namespace's validator, so an unserviceable profile is refused where it is * *written* — `settings.mutate` answers `settings-rejected` with the offending * route and model named — instead of being stored and then quietly disabling * every route in the namespace. It stays a validator rather than a schema * transform because the schema is also the shape a configuration surface * renders and the value an absent section resolves to; wrapping it would break * both. * @param config - the resolved section to check. * @throws Error naming the route and model that cannot be served. */ export function assertServiceable(config: Config): void { resolveProfiles(config.providers) } /** Reject removed pre-release profile fields and name their replacements. */ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { const legacy = source as PiAiProviderProfile & { provider?: unknown maxRetries?: unknown maxRetryDelayMs?: unknown } if ('provider' in legacy) { throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`) } if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { throw new Error( `llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;` + ' compose agent recovery with dsh-llm-retry', ) } } /** * Validate profiles and return a detached route-keyed map suitable for * per-request reads. This is the one explicit resolve step, so an omitted dict * resolves to the empty (dormant) route set here rather than through a hidden * fallback, and each route's models and pi-ai provider are materialized once. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ export function resolveProfiles( providers: Readonly> | undefined, ): Map { if (Array.isArray(providers)) { throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } const entries = Object.entries(providers ?? {}) const resolved = new Map() for (const [provider, source] of entries) { rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } if (source.displayName !== undefined && source.displayName.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`) } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { throw new Error( `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } // The route key, not the installed provider's own name: the directory has // always shown route keys, and a catalog route must not silently rename // itself on every configuration surface just because it gained a profile. const displayName = source.displayName ?? provider const catalog = resolveRouteModels({ provider, ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides }, ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }) const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, provider, displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, configuredMaxTokens: catalog.configuredMaxTokens, piProvider: buildProvider({ provider, displayName, ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, models: catalog.models, namesCredential: apiKeyEnv !== undefined, }), }) } return resolved }