Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a

# Conflicts:
#	docs/config-catalog.md
#	packages/llm/llm-pi-ai/src/config.ts
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-06 16:52:19 +08:00
157 changed files with 8735 additions and 942 deletions
+140 -62
View File
@@ -1,17 +1,33 @@
/**
* Generic pi-ai-backed implementation of the Harness LLM seam.
*
* Each resolution produces one **immutable** snapshot — the profiles plus a
* `Models` collection holding the `Provider` each route built — and an
* operation captures a whole snapshot before its first `await`. A
* configuration change builds a *new* collection rather than mutating the one
* in use, because `Models.streamSimple()` is lazy: it resolves the provider
* when the stream is first consumed, which is after the credential await, so a
* mutated collection would let a request that started under one configuration
* finish under another — or fail with a provider that no longer exists. This is
* what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
* way down: switching models mid-reply takes effect on the next step, never
* inside the one in flight.
*
* Credentials stay outside that collection. The harness resolves a route's key
* through its own seam and passes it as the request's `apiKey` option, which
* pi-ai treats as the highest-priority auth override — so `Models` never holds
* a credential store and the harness keeps its fail-loud reference semantics.
*
* @module dsh-llm-pi-ai/adapter
*/
import { streamSimple } from '@earendil-works/pi-ai/compat'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type {
Api,
Model,
Models,
ModelThinkingLevel,
MutableModels,
SimpleStreamOptions,
ThinkingLevel,
} from '@earendil-works/pi-ai'
@@ -24,6 +40,7 @@ import {
import type {
GenerateOptions,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
ReasoningEffortId as ReasoningEffortIdType,
ResolvedRetryPolicy,
@@ -34,35 +51,29 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** One resolution's frozen view: the profiles and the collection built from them. */
interface PiAiSnapshot {
/** The resolved profiles this collection was built from, used as its identity. */
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
/** Providers for exactly those profiles; never mutated once published. */
models: Models
}
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
export interface PiAiAdapterOptions {
/** Current validated profiles by provider route; called once per operation. */
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
/**
* Resolve the credential for one already-resolved profile; called once per
* stream call and frozen for that call. `undefined` defers to pi-ai's
* provider-native ambient discovery, which the plugin allows only for a
* profile naming no credential at all; a named reference that misses throws
* `LlmError` `MISSING_CREDENTIAL` rather than falling back.
* stream call and frozen for that call. `undefined` defers to the route's own
* pi-ai auth, which for an installed catalog route is its provider-native
* ambient discovery; the plugin allows that only for a profile naming no
* credential at all, because a named reference that misses throws `LlmError`
* `MISSING_CREDENTIAL` rather than falling back.
*/
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
}
/**
* Resolve a catalog model dynamically and apply only the configured endpoint
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolvePiModel(
profile: ResolvedPiAiProviderProfile,
modelId: string,
): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
}
return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL }
}
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: ResolvedPiAiProviderProfile,
@@ -97,6 +108,39 @@ function resolveReasoningLevel(
)
}
/**
* Selectable reasoning efforts for one model, or nothing at all.
*
* A model that carries no reasoning metadata — every hand-declared one, and
* every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
* supporting 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()
@@ -108,28 +152,72 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
}
/**
* pi-ai-backed multi-provider adapter. Model descriptors are resolved for each
* request, so models need not be registered during the Cordis lifecycle.
* pi-ai-backed multi-provider adapter. Each operation reads the current
* profiles, so a configuration change reaches the next request without a
* restart; model descriptors come from the collection those profiles built.
*/
export class PiAiAdapter extends LlmAdapter {
private snapshot: PiAiSnapshot | undefined
constructor(private readonly config: PiAiAdapterOptions) {
super()
}
/**
* The snapshot for the current profiles. Resolution memoizes its result, so
* an unchanged configuration is recognized by identity; a changed one gets a
* brand-new collection, leaving any snapshot an operation already captured
* untouched for as long as that operation holds it.
*/
private current(): PiAiSnapshot {
const profiles = this.config.profiles()
if (this.snapshot?.profiles === profiles) return this.snapshot
const models: MutableModels = createModels()
for (const profile of profiles.values()) models.setProvider(profile.piProvider)
this.snapshot = { profiles, models }
return this.snapshot
}
/** The profile for one route within one snapshot, or the not-owned failure. */
private profileOf(snapshot: PiAiSnapshot, provider: string): ResolvedPiAiProviderProfile {
const profile = snapshot.profiles.get(provider)
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')
}
return profile
}
/** The configured descriptor for one exact route/model pair within one snapshot. */
private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model<Api> {
this.profileOf(snapshot, provider)
const resolved = snapshot.models.getModel(provider, model)
if (resolved === undefined) {
throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL')
}
return resolved
}
override providerInfo(provider: string): LlmProviderInfo {
// The configured name, not the route key: `displayName` exists so a
// deployment can label a route, and a label only the configuration surface
// reads would leave every selector showing the raw key.
return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider }
}
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
return this.config.profiles().get(provider)?.retryPolicy
return this.current().profiles.get(provider)?.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({
provider,
id: model.id,
name: model.name,
})))
return Promise.resolve().then(() => {
const snapshot = this.current()
this.profileOf(snapshot, provider)
return snapshot.models.getModels(provider).map(model => ({
provider,
id: model.id,
name: model.name,
}))
})
}
override resolveModel(
@@ -137,31 +225,21 @@ export class PiAiAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
'NO_ADAPTER',
))
}
return Promise.resolve().then(() => {
const resolvedModel = resolvePiModel(profile, model)
const levels = getSupportedThinkingLevels(resolvedModel)
const snapshot = this.current()
const profile = this.profileOf(snapshot, provider)
const resolvedModel = this.modelOf(snapshot, provider, model)
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.
const configuredMaxTokens = profile.configuredMaxTokens.get(model)
return {
provider,
id: model,
name: resolvedModel.name,
context: { contextWindow: resolvedModel.contextWindow },
reasoning: {
efforts: levels.map(level => ({
id: ReasoningEffortId(level),
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`,
})),
...defaultLevel === undefined
? {}
: { defaultEffort: ReasoningEffortId(defaultLevel) },
},
...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens },
...reasoningInfo(resolvedModel, defaultLevel),
}
})
}
@@ -170,14 +248,14 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
// One resolution per stream call: the profile snapshot and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
const profile = this.config.profiles().get(options.provider)
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
const model = resolvePiModel(profile, options.model)
// One capture per stream call, taken before any await: the profile, the
// model descriptor, and the collection all come from the same immutable
// snapshot, and the credential freezes with them. A configuration change
// mid-request builds a separate snapshot, so this request finishes under
// the one it started with and the next call picks up the new one.
const snapshot = this.current()
const profile = this.profileOf(snapshot, options.provider)
const model = this.modelOf(snapshot, options.provider, options.model)
const reasoning = resolveReasoningLevel(
model,
options.reasoningEffort ?? profile.reasoning,
@@ -192,7 +270,7 @@ export class PiAiAdapter extends LlmAdapter {
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
try {
const events = streamSimple(model, toPiContext(options), {
const events = snapshot.models.streamSimple(model, toPiContext(options), {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
+223
View File
@@ -0,0 +1,223 @@
/**
* Materialization of one provider route's model catalog. The installed pi-ai
* catalog supplies defaults keyed by model id, and a profile's own model
* entries override them field by field, so a route naming a catalog provider
* stays configuration-free while a route pi-ai has never heard of is fully
* describable from `settings.yaml`.
*
* Every pi-ai `Model` field the harness cannot default is required here rather
* than at request time: an unserviceable route fails while its configuration is
* being resolved, which is the earliest point that can name the offending key.
*
* @module dsh-llm-pi-ai/catalog
*/
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai'
/**
* Pricing for a model the installed catalog does not describe. The harness
* never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
* reports spend — so this is the absence of a fact, not a configurable rate.
*/
const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
/**
* Input modalities for a model the installed catalog does not describe. The
* request converter keeps only text blocks, so text is the adapter's actual
* capability rather than a deployment choice.
*/
const TEXT_ONLY: Model<Api>['input'] = ['text']
let providerIndex: Map<string, Provider> | undefined
/**
* Installed catalog providers by id, constructed once. Each entry owns the API
* implementations for its own models, which is why a catalog route reuses this
* provider instead of being rebuilt from parts.
* @returns the catalog provider index.
*/
function catalogProviders(): Map<string, Provider> {
providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider]))
return providerIndex
}
/**
* The installed catalog provider for one route, when pi-ai ships one.
* @param provider - provider route key.
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
*/
export function catalogProvider(provider: string): Provider | undefined {
return catalogProviders().get(provider)
}
/**
* Every provider route the installed pi-ai catalog ships.
* @returns the catalog provider ids.
*/
export function catalogProviderIds(): readonly string[] {
return getBuiltinProviders()
}
/**
* The installed catalog models for one route, indexed by model id.
* @param provider - provider route key.
* @returns catalog models by id; empty for a route pi-ai does not ship.
*/
export function catalogModels(provider: string): Map<string, Model<Api>> {
if (!catalogProviders().has(provider)) return new Map()
const models = getBuiltinModels(provider as BuiltinProvider) as Model<Api>[]
return new Map(models.map(model => [model.id, model]))
}
/** One configured model entry: an id plus the catalog fields it overrides. */
export interface PiAiModelProfile {
/** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */
id: string
/** Display name for selectors; defaults to the catalog name, then the id. */
name?: string
/** Maximum combined request and response context in tokens. */
contextWindow?: number
/**
* Maximum output tokens. Configuring one also makes it this model's
* 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
}
/** The route-level facts model materialization reads. */
export interface RouteCatalogRequest {
/** Provider route key, stamped onto every materialized model. */
provider: string
/** Wire protocol override; absent defers to each catalog model's own API. */
api?: string
/** Endpoint override; absent defers to the catalog model, then the catalog provider. */
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. */
function invalid(provider: string, detail: string): never {
throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`)
}
/**
* The one wire protocol a catalog route's shipped models agree on. This is what
* lets a deployment add a model the installed catalog has not caught up with —
* a provider's newest release — without restating the protocol its siblings
* already use. A route whose shipped models disagree (an OpenAI-style catalog
* spanning Responses and Chat Completions) has no such answer, so a model it
* does not describe must name its protocol at the route.
*/
function sharedCatalogApi(defaults: ReadonlyMap<string, Model<Api>>): string | undefined {
const apis = new Set<string>()
for (const model of defaults.values()) apis.add(model.api)
return apis.size === 1 ? [...apis][0] : undefined
}
/** One route's materialized catalog, plus the request caps its profile chose. */
export interface RouteCatalog {
/** The materialized models in configuration order. */
models: readonly Model<Api>[]
/**
* Per-request output caps this profile explicitly configured, by model id.
*
* Separate from `Model.maxTokens` because the two answer different
* questions: pi-ai requires `maxTokens` as the model's output *capability*,
* while the harness seam's `defaultMaxTokens` is a cap the deployment chose
* to send on requests that name none. Materializing a catalog capability as
* a request default would start capping every request at a number nobody
* picked, so only an explicit configuration lands here.
*/
configuredMaxTokens: ReadonlyMap<string, number>
}
/**
* Materialize one route's catalog by merging the installed catalog defaults
* under the configured entries. A route with no configured `models` serves the
* installed catalog unchanged, which is what keeps an existing
* `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
* @param request - the route-level catalog facts.
* @returns the materialized models and the explicitly configured request caps.
*/
export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
const { provider } = request
const defaults = catalogModels(provider)
const providerBaseUrl = catalogProvider(provider)?.baseUrl
// An absent `models` key and an empty one are the same request: the config
// schema materializes `[]` for the absent case, and an empty catalog could
// serve no request anyway, so both mean "serve the installed catalog".
const configured = request.models ?? []
const entries: readonly PiAiModelProfile[] = configured.length > 0
? configured
: [...defaults.values()].map(model => ({ id: model.id }))
if (entries.length === 0) {
invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models'
+ ' must be listed in configuration')
}
const routeApi = sharedCatalogApi(defaults)
const seen = new Set<string>()
const configuredMaxTokens = new Map<string, number>()
const models = entries.map((entry) => {
if (entry.id.length === 0) invalid(provider, 'has a model with an empty id')
if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`)
seen.add(entry.id)
const base = defaults.get(entry.id)
const api = request.api ?? base?.api ?? routeApi
if (api === undefined) {
invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the`
+ ' route\'s api to the wire protocol its endpoint speaks')
}
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
if (baseUrl === undefined) {
invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`)
}
// 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 ?? request.defaultMaxTokens
if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`)
}
// Only a value the profile named is a deployment choice; the catalog's is
// the model's capability and stays out of request defaults.
if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
return {
// The installed entry lays the floor, and the fields below override it.
// Enumerating instead would silently drop every `Model` field this
// package does not model — reasoning-level spellings, compatibility
// quirks, model headers, and whatever a pi-ai upgrade adds next. That is
// not hypothetical: `headers` reached this file only after an nvidia
// route lost it, and a rebuild keeps re-earning that bug on every
// upgrade.
...base,
id: entry.id,
name: entry.name ?? base?.name ?? entry.id,
api,
provider,
baseUrl,
// 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,
maxTokens,
}
})
return { models, configuredMaxTokens }
}
+142 -23
View File
@@ -3,27 +3,71 @@
* 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
/** 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 {
/** 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[]
/**
* 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. */
@@ -45,15 +89,31 @@ 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
/**
* 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. */
@@ -73,9 +133,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),
})
const profile = z.object({
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,
@@ -93,10 +165,44 @@ export const Config: z<Config> = z.object({
})
/**
* 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.
* 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.
*/
@@ -107,25 +213,16 @@ 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.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
@@ -134,15 +231,37 @@ 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 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,
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
+262
View File
@@ -0,0 +1,262 @@
/**
* Answering "which models can this provider serve?" for the configuration
* surface's "fetch available models" action.
*
* A route the installed pi-ai catalog ships is answered **from that catalog**,
* with no network call at all: pi-ai's registry is the authoritative list for
* its own providers, and it carries the capacities a listing endpoint would
* not disclose. Only a route the catalog does not describe — a gateway, a
* self-hosted server — is interrogated over the wire.
*
* Neither path is a catalog refresh. Nothing here is stored: the request
* carries a draft the user is still editing, and the reply is candidate
* metadata the surface offers for adoption. `settings.yaml` remains the only
* thing that decides what a route serves.
*
* Only OpenAI-compatible protocols are interrogated. Their listing is the one
* shape a gateway, a self-hosted server, and the official endpoints all agree
* on, which is the case this action exists for; every other protocol reports
* that it cannot be interrogated so the surface falls back to hand-entry
* rather than guessing a response shape.
*
* @module dsh-llm-pi-ai/discovery
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
import { catalogModels } from './catalog.ts'
/**
* Protocols whose model listing this module can read: the two that speak
* OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its
* OpenAI lineage — it authenticates with an `api-key` header and requires an
* `api-version` query — and Codex authenticates through OAuth; guessing at
* either would report an authentication failure as a provider with no models.
* pi-ai's remaining protocols are absent for the same reason.
*/
const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
'openai-completions',
'openai-responses',
])
/**
* Endpoint replies larger than this are refused. The endpoint is whatever URL
* the user typed, so the ceiling holds on the bytes actually read rather than
* on the length the server claims — the same two-stage shape `dsh-web-fetch`
* uses for its own caller-supplied URLs, except that a truncated model listing
* is not parseable, so overflow rejects instead of truncating.
*/
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
/** One entry of an OpenAI-compatible `GET /models` reply. */
interface ListingEntry {
id?: unknown
/** Common gateway extensions; absent from the official listings. */
name?: unknown
display_name?: unknown
context_window?: unknown
context_length?: unknown
max_tokens?: unknown
max_output_tokens?: unknown
}
/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
function capacity(...candidates: readonly unknown[]): number | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate
}
return undefined
}
/** A non-empty string field of a listing entry, or `undefined`. */
function label(...candidates: readonly unknown[]): string | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) return candidate
}
return undefined
}
/**
* Join the endpoint base with the listing path. The base is treated as a
* prefix rather than a URL to resolve against, so a deployment path such as
* `https://gateway.example/openai/v1` keeps its segments instead of losing
* them to `URL` resolution.
*/
function listingUrl(baseURL: string): string {
return `${baseURL.replace(/\/+$/, '')}/models`
}
/**
* Read a reply body, refusing one that outgrows the ceiling. A declared length
* is checked first so an honest server is turned away without transferring
* anything; the accumulated total is what actually enforces the bound, because
* a server that under-declares (or streams) tells us nothing up front.
*/
async function readBounded(response: Response, url: string): Promise<string> {
const oversized = (): LlmError =>
new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED')
const declared = Number(response.headers.get('content-length') ?? Number.NaN)
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
await response.body?.cancel()
throw oversized()
}
/* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
if (response.body === null) return ''
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) throw oversized()
chunks.push(value)
}
} finally {
/* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
await reader.cancel().catch(() => {
// Cancel after a drained read, or after this function walked away from
// an oversized one, is cleanup; the reply is already decided either way.
})
}
const body = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(body)
}
/**
* Read one OpenAI-compatible listing reply. Entries without a usable id are
* skipped rather than failing the whole interrogation: a single malformed row
* should not deny the user the rest of a working endpoint's catalog.
*/
function readListing(body: unknown): LlmDiscoveredModel[] {
const data = (body as { data?: unknown } | null)?.data
if (!Array.isArray(data)) {
throw new LlmError(
'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand',
'DISCOVERY_FAILED',
)
}
const models: LlmDiscoveredModel[] = []
for (const raw of data) {
const entry = raw as ListingEntry | null
const id = label(entry?.id)
if (id === undefined) continue
const name = label(entry?.name, entry?.display_name)
const contextWindow = capacity(entry?.context_window, entry?.context_length)
const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens)
models.push({
id,
...name === undefined ? {} : { name },
...contextWindow === undefined ? {} : { contextWindow },
...maxTokens === undefined ? {} : { maxTokens },
})
}
return models
}
/**
* Interrogate one draft provider endpoint for the models it advertises.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @param storedApiKey - the credential the named route already stored, asked
* for only when the draft carries none and only on the path that reaches the
* network. A configuration surface never holds a stored secret — it edits a
* redacted descriptor — so without this an already-configured route would be
* interrogated unauthenticated and answer 401.
* @returns the advertised models in endpoint order.
* @throws LlmError when the protocol has no readable listing, the endpoint
* refuses or fails the request, or the reply is not a model listing.
*/
export async function discoverModels(
request: LlmModelDiscoveryRequest,
storedApiKey?: () => Promise<string | undefined>,
): Promise<readonly LlmDiscoveredModel[]> {
// A catalog route already has its answer, and a better one: the installed
// entries carry context windows and output caps no listing endpoint reports.
if (request.provider !== undefined) {
const installed = catalogModels(request.provider)
if (installed.size > 0) {
return [...installed.values()].map(model => ({
id: model.id,
name: model.name,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}))
}
}
if (request.baseURL === undefined || request.baseURL.length === 0) {
throw new LlmError(
`pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its`
+ " endpoint; set a baseURL, or enter this provider's models by hand",
'DISCOVERY_FAILED',
)
}
// A draft that has not chosen a protocol yet is asked as OpenAI Chat
// Completions: it is the shape a gateway is overwhelmingly likely to speak,
// and the alternative — refusing until the field is filled — would withhold
// the action from the case it exists for. The cost is a misdirected message
// when the endpoint speaks something else (an Anthropic gateway answers 401,
// which reads as a credential problem), and hand-entry remains the way out.
const api = request.api ?? 'openai-completions'
if (!LISTABLE_PROTOCOLS.has(api)) {
throw new LlmError(
`pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`,
'DISCOVERY_UNSUPPORTED',
)
}
const url = listingUrl(request.baseURL)
// A key typed into the form wins: it is the one the user is testing, and it
// may be the replacement for exactly the stored key that is failing. The
// stored one is only asked for here, past the catalog short-circuit and the
// protocol check, so a route answered from the registry costs no credential
// lookup — and no diagnostic about a credential it never needed.
const apiKey = request.apiKey ?? await storedApiKey?.()
let response: Response
try {
response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` },
...attributionHeaders(),
},
...request.signal === undefined ? {} : { signal: request.signal },
})
} catch (error: unknown) {
if (request.signal?.aborted) {
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
}
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error })
}
if (!response.ok) {
throw new LlmError(
`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
'DISCOVERY_FAILED',
)
}
let text: string
try {
text = await readBounded(response, url)
} catch (error: unknown) {
// Cancellation during the body read rejects with the abort reason, which
// may be any value; the caller gets the same coded failure it would have
// for a cancellation before the request went out.
if (request.signal?.aborted) {
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
}
throw error
}
let body: unknown
try {
body = JSON.parse(text)
} catch (error: unknown) {
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error })
}
return readListing(body)
}
+142 -38
View File
@@ -1,10 +1,11 @@
/**
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
* provider routes; requests select a profile by provider and resolve the
* model dynamically from pi-ai's installed catalog. Profile facts resolve per
* request over the optional `llm-pi-ai` user-settings section and the
* optional credential seam, so a changed key, endpoint, or knob reaches the
* next request without a restart; a changed *route set* (or a route's
* provider routes; a route naming an installed pi-ai provider inherits that
* provider's endpoint, protocol, and model catalog as defaults, and a route
* pi-ai does not ship is declared outright. Profile facts resolve per request
* over the optional `llm-pi-ai` user-settings section and the optional
* credential seam, so a changed key, endpoint, model, or knob reaches the next
* request without a restart; a changed *route set* (or a route's
* registration-captured retry policy) re-registers the same adapter instance
* in place.
*
@@ -13,16 +14,29 @@
* name: '@deepseek-ai/dsh-llm-pi-ai'
* config:
* providers:
* # Catalog route: everything but the credential comes from pi-ai.
* openai:
* apiKeyEnv: OPENAI_API_KEY
* retryPolicy:
* mode: normal
* maxRetries: 2
* # Catalog route with the catalog narrowed and one capacity corrected.
* anthropic:
* apiKeyEnv: ANTHROPIC_API_KEY
* openrouter:
* apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1
* models:
* - id: claude-sonnet-4-5
* contextWindow: 200000
* # Hand-declared route: pi-ai ships nothing under this key.
* acme-gateway:
* displayName: Acme Gateway
* apiKeyEnv: ACME_GATEWAY_API_KEY
* api: openai-completions
* baseURL: https://gateway.acme.example/v1
* models:
* - id: acme-large
* name: Acme Large
* contextWindow: 65536
* maxTokens: 4096
* ```
*
* @module @deepseek-ai/dsh-llm-pi-ai
@@ -30,18 +44,20 @@
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts'
import { Config, resolveProfiles } from './config.ts'
import { catalogProviderIds } from './catalog.ts'
import { assertServiceable, Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { discoverModels } from './discovery.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
export { Config } from './config.ts'
export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export { supportedProtocols } from './provider.ts'
export const name = 'llm-pi-ai'
export const inject = ['llm']
@@ -55,33 +71,60 @@ const NS = settingsNamespace('llm-pi-ai')
*/
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()]
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
// `displayName` rides along because the registry hands it to every selector
// through `providerInfo()`: a rename that did not re-register would leave
// the old label showing until some unrelated fact happened to change.
.map(([provider, profile]) => ({
provider,
displayName: profile.displayName,
retryPolicy: profile.retryPolicy,
}))
.sort((left, right) => left.provider.localeCompare(right.provider))
}
/**
* The configurable-provider directory: every installed catalog route, plus
* every route the current profiles declare. A hand-declared route has no
* catalog entry, so without this union it would have no settings address and
* configuration surfaces could neither show nor edit it.
* @param profiles - the currently resolved provider profiles.
* @returns the directory entries in catalog order, declared routes last.
*/
function directoryEntries(
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
): LlmConfigurableProvider[] {
const entries = new Map<string, LlmConfigurableProvider>()
const declare = (provider: string, displayName: string): void => {
entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] })
}
for (const provider of catalogProviderIds()) declare(provider, provider)
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
return [...entries.values()]
}
/** Register one generic pi-ai adapter for all configured provider routes. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
let lastRaw: Config | undefined
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
let memoized: 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
}
if (raw === lastRaw && memoized !== undefined) return memoized
const next = resolveProfiles(raw.providers)
lastRaw = raw
memoized = next
return next
}
profiles()
@@ -113,13 +156,46 @@ export function apply(ctx: Context, config: Config): void {
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
// The full installed catalog is configurable from the moment the plugin
// mounts — dormant or not — so configuration surfaces can offer every
// pi-ai provider before any route exists.
ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({
provider,
displayName: provider,
settingsNs: NS,
settingsPath: ['providers', provider],
})))
// pi-ai provider before any route exists. Hand-declared routes join it as
// profiles appear, and leave with them.
let directory: DirectoryRegistrationHandle | undefined
let directoryFacts: unknown
const ensureDirectory = (): void => {
const entries = directoryEntries(profiles())
if (deepEqualJson(entries, directoryFacts)) return
// Atomic replace, never dispose-then-register: a route another adapter
// family already declares (a profile keyed `deepseek-official`) would
// otherwise leave this plugin's whole directory withdrawn and the Models
// page empty. The candidate set is validated first, so a collision keeps
// the previous entries serving and only costs a diagnostic.
if (directory === undefined) {
directory = ctx.llm.registerConfigurableProviders(entries)
} else {
directory.replace(entries)
}
directoryFacts = entries
}
ensureDirectory()
/**
* The credential a named route already resolves, for an interrogation whose
* draft carries none. A route being declared for the first time names no
* profile yet, and a profile that names no credential defers to pi-ai's own
* discovery, so both answer `undefined` and the endpoint is asked
* unauthenticated — the same posture a request to that route would take.
*/
const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
if (provider === undefined) return undefined
const profile = profiles().get(provider)
if (profile === undefined) return undefined
return resolveApiKey(provider, profile)
}
// Interrogating an endpoint is a configuration-time action over a draft, so
// it is offered for the whole namespace rather than per route: the provider
// a surface is adding does not exist yet. The draft is the whole request
// except the credential: a configuration surface edits a redacted descriptor
// and never holds a stored secret, so an already-configured route supplies
// its own here rather than being interrogated unauthenticated.
ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider)))
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a
@@ -152,9 +228,37 @@ 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
},
onChange: ensureRegistrationFacts,
onChange: () => {
// Named here rather than left to the settings watcher: `assertServiceable`
// cannot see the llm registry, so a profile claiming a route another
// adapter family owns is stored successfully and only fails at this swap.
// Without its own diagnostic that refusal reaches the operator as a
// generic "settings: watcher failed", naming neither the route nor why it
// is not serving. The previous routes keep serving either way.
try {
ensureRegistrationFacts()
} catch (error) {
ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update')
ctx.logger.error(error)
}
// The directory follows the profiles the registry accepted, so a route
// that failed to register is not advertised as configurable. A refused
// directory swap is contained here for the same reason the registry's
// is: the previous entries keep serving, and `directoryFacts` stays put
// so returning to a working configuration re-applies.
try {
ensureDirectory()
} catch (error) {
ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update')
ctx.logger.error(error)
}
},
})
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Construction of the pi-ai `Provider` that one configured route registers into
* the adapter's `Models` collection.
*
* Two constructions, one decision: a route the installed catalog ships, whose
* profile does not override the wire protocol, **reuses that catalog provider**
* with its models replaced — the catalog provider owns API implementations this
* package cannot reconstruct (Bedrock loads its Smithy module through a
* separate entry point), so rebuilding it from parts would silently narrow
* which providers work. Every other route — one pi-ai has never heard of, or a
* catalog route pointed at a different protocol — is built by `createProvider`
* over the protocol table below.
*
* Credentials never reach this module's storage: the harness resolves a route's
* key through `ctx.credentials` before the request enters pi-ai and hands it
* over as a stream option, which `Models` presents to `resolve()` as the
* credential key.
*
* @module dsh-llm-pi-ai/provider
*/
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 { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
import { catalogProvider } from './catalog.ts'
/**
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
* implementations. Each entry is the factory that pi-ai's matching provider
* factory uses, so a hand-declared route reaches exactly the implementation a
* catalog route would.
*
* 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. 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>> = {
'openai-completions': openAICompletionsApi,
'openai-responses': openAIResponsesApi,
'anthropic-messages': anthropicMessagesApi,
}
/**
* 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)
}
/**
* Api-key auth for a route the harness authenticates itself. `Models` calls
* this after the adapter has already resolved the route's credential, so a
* missing key here is not this layer's failure: a named-but-unresolvable
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
* route naming no credential at all is deliberately unauthenticated. Reporting
* it as configured hands the decision to the protocol, which is where the
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
* one, still insists on a key or an `Authorization` header of its own.
* @param name - display name used as the resolution's status label.
* @returns the api-key auth for a harness-authenticated route.
*/
function harnessApiKeyAuth(name: string): ApiKeyAuth {
return {
name,
resolve: ({ credential }) => Promise.resolve({
auth: credential?.key === undefined ? {} : { apiKey: credential.key },
source: name,
}),
}
}
/** The resolved route facts provider construction reads. */
export interface ProviderSpec {
/** Provider route key; also the `Models` collection key and each model's `provider`. */
provider: string
/** Display name for selectors and status labels. */
displayName: string
/** Wire protocol override; absent means each model keeps its catalog protocol. */
api?: string
/** Endpoint override already applied to {@link models}; kept for provider-level display. */
baseURL?: string
/** The route's materialized models, in configuration order. */
models: readonly Model<Api>[]
/**
* Whether the profile names a credential, which it does through `apiKeyEnv`
* alone: configuration carries the reference, never the secret. Only that
* decides whether {@link routeAuth} adds the harness's own api-key method to
* a catalog provider that offers none; the key itself still arrives per
* request, never at construction.
*/
namesCredential: boolean
}
/**
* The auth one route resolves its credential through.
*
* A catalog route keeps the installed provider's own auth, which is what
* preserves provider-native ambient discovery for a profile naming no
* credential. That holds even when the profile repoints the protocol: which
* environment a provider reads is a property of the provider, not of the wire
* format its models speak.
*
* The single addition covers a catalog provider that offers no api-key method
* at all. pi-ai resolves a request's `apiKey` override only when the provider
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
* honouring the override), so an OAuth-only provider — `openai-codex` is the
* one the installed catalog ships — would refuse a profile's explicit key with
* `Provider is not configured` before any request went out. Adding the harness
* method beside the provider's own restores that route. A keyless profile adds
* nothing and still reports the honest refusal, because this adapter resolves
* credentials through its own seam and holds no OAuth store to fall back on.
* @param spec - the resolved route facts.
* @param catalog - the installed catalog provider, when pi-ai ships one.
* @returns the auth to construct this route's provider with.
*/
function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] {
if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) }
if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth
return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) }
}
/**
* Reuse an installed catalog provider with this route's models and identity.
* Model dispatch stays with the catalog provider, so its API implementations,
* compatibility quirks, and ambient credential discovery are preserved exactly.
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
* settings document, and a background refresh would contradict it.
*/
function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider {
// Provider-level `baseUrl` is display metadata: pi-ai routes every request
// through `Model.baseUrl`, which model resolution has already overridden.
const baseUrl = spec.baseURL ?? base.baseUrl
return {
id: spec.provider,
name: spec.displayName,
...baseUrl === undefined ? {} : { baseUrl },
auth: routeAuth(spec, base),
getModels: () => spec.models,
// Delegated rather than copied: the catalog provider stays the receiver, so
// an implementation holding state on itself keeps working.
stream: (model, context, options) => base.stream(model, context, options),
streamSimple: (model, context, options) => base.streamSimple(model, context, options),
}
}
/**
* Build the pi-ai provider for one resolved route.
* @param spec - the resolved route facts.
* @returns the provider to register in the adapter's `Models` collection.
* @throws Error when the route names a wire protocol this build cannot serve.
*/
export function buildProvider(spec: ProviderSpec): Provider {
const catalog = catalogProvider(spec.provider)
// A catalog route keeping its catalog protocol reuses the catalog provider;
// an explicit protocol means the deployment is repointing the route at a
// different wire format, which only the protocol table can serve.
if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec)
// Every model on this path carries the route's protocol: model resolution
// requires one for a route the catalog cannot default, and an explicit one
// replaces each catalog model's own. So the route has a single API.
const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api]
if (factory === undefined) {
throw new Error(
`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;`
+ ` supported protocols are ${supportedProtocols().join(', ')}`,
)
}
return createProvider({
id: spec.provider,
name: spec.displayName,
...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL },
auth: routeAuth(spec, catalog),
models: spec.models,
api: factory(),
})
}