Files
deepseek-harness/packages/llm/llm-pi-ai/src/config.ts
T

290 lines
13 KiB
TypeScript

/**
* 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 { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { resolveRouteModels } from './catalog.ts'
import type { PiAiModelProfile } 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 { PiAiModelProfile } from './catalog.ts'
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/**
* Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
* provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
* value no HTTP header can carry fails there rather than inside `fetch`.
*/
apiKey?: string
/** 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[]
/**
* 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. */
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<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy' | 'models' | 'displayName'> {
/** 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<string, number>
}
/** 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<string, PiAiProviderProfile>
}
const thinkingBudgets = z.object({
minimal: z.number(),
low: z.number(),
medium: z.number(),
high: z.number(),
})
const modelProfile: z<PiAiModelProfile> = z.object({
id: z.string().required(),
name: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
})
const profile = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref'),
displayName: z.string(),
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,
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<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 & {
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<Record<string, PiAiProviderProfile>> | undefined,
): Map<string, ResolvedPiAiProviderProfile> {
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<string, ResolvedPiAiProviderProfile>()
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')
// Omission selects the installed provider's own auth — ambient discovery
// or OAuth — so only a supplied key is judged.
let apiKey: string | undefined
if (source.apiKey !== undefined) {
const checked = normalizeApiKey(source.apiKey)
if (!checked.ok) {
throw new Error(checked.reason === 'empty'
? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`
: `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;`
+ ' paste the raw key only')
}
apiKey = checked.value
}
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 },
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,
...apiKey === undefined ? {} : { apiKey },
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: source.apiKey !== undefined || apiKeyEnv !== undefined,
}),
})
}
return resolved
}