feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that catalog's models verbatim, and could override only the endpoint. An OpenAI-compatible gateway, a self-hosted server, or a model newer than the pinned pi-ai release was therefore unreachable, and a stale context window could not be corrected without upgrading the package. A route is now a declaration whose defaults come from the installed catalog. `catalog.ts` merges that catalog under the profile's own model entries, `provider.ts` builds the pi-ai Provider (reusing the catalog provider when the route keeps its protocol, so implementations this package cannot reconstruct keep working), and the adapter serves every operation from one `createModels()` collection. That also retires the `@earendil-works/pi-ai/compat` import, which pi-ai documents as a temporary entry point it deletes with its ModelManager migration. Credentials stay on the harness seam: the resolved key rides the request as pi-ai's highest-priority auth override, so `Models` holds no credential store and a named-but-missing reference still fails loud instead of falling back to an unrelated ambient key. A model's configured maxTokens now reaches the seam as defaultMaxTokens.
This commit is contained in:
@@ -3,29 +3,55 @@
|
||||
* 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 { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
|
||||
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 } 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
|
||||
|
||||
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. */
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
||||
apiKeyEnv?: string
|
||||
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
|
||||
/** 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[]
|
||||
/** Provider request headers; Harness attribution wins reserved names. */
|
||||
headers?: Record<string, string>
|
||||
/** Provider-neutral pi-ai reasoning level. */
|
||||
@@ -47,15 +73,25 @@ export interface PiAiProviderProfile {
|
||||
}
|
||||
|
||||
/** Validated profile with its route stamped and every adapter-owned default resolved. */
|
||||
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy'> {
|
||||
/** pi-ai provider catalog name and Harness route key (the configuration dict key). */
|
||||
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
|
||||
}
|
||||
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
@@ -75,10 +111,21 @@ const thinkingBudgets = z.object({
|
||||
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),
|
||||
reasoning: z.boolean(),
|
||||
})
|
||||
|
||||
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),
|
||||
headers: z.dict(z.string()),
|
||||
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
thinkingBudgets,
|
||||
@@ -95,11 +142,29 @@ export const Config: z<Config> = z.object({
|
||||
providers: z.dict(profile).default({}),
|
||||
})
|
||||
|
||||
/** 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 against the installed pi-ai catalog 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.
|
||||
* 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.
|
||||
*/
|
||||
@@ -110,28 +175,19 @@ export function resolveProfiles(
|
||||
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 supported = new Set<string>(getBuiltinProviders())
|
||||
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
|
||||
for (const [provider, source] of entries) {
|
||||
const legacy = source as PiAiProviderProfile & {
|
||||
provider?: unknown
|
||||
maxRetries?: unknown
|
||||
maxRetryDelayMs?: unknown
|
||||
}
|
||||
if ('provider' in legacy) {
|
||||
throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key')
|
||||
}
|
||||
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
|
||||
throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
|
||||
}
|
||||
rejectRemovedFields(provider, source)
|
||||
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
|
||||
if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
|
||||
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
|
||||
}
|
||||
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
|
||||
@@ -140,15 +196,33 @@ export function resolveProfiles(
|
||||
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
const { apiKeyEnv, retryPolicy, ...rest } = source
|
||||
// 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 models = resolveRouteModels({
|
||||
provider,
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
...source.models === undefined ? {} : { models: source.models },
|
||||
})
|
||||
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 } },
|
||||
piProvider: buildProvider({
|
||||
provider,
|
||||
displayName,
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
models,
|
||||
}),
|
||||
})
|
||||
}
|
||||
return resolved
|
||||
|
||||
Reference in New Issue
Block a user