# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/event-producer-consumer.md # examples/headless-agent/tests/headless.snapshot.ts # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # packages/llm/llm-deepseek/README.i18n.yaml # packages/llm/llm-deepseek/src/index.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/src/index.ts
162 lines
6.7 KiB
TypeScript
162 lines
6.7 KiB
TypeScript
/**
|
|
* 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
|
|
* registration-captured retry policy) re-registers the same adapter instance
|
|
* in place.
|
|
*
|
|
* ```yaml
|
|
* - id: llm
|
|
* name: '@deepseek-ai/dsh-llm-pi-ai'
|
|
* config:
|
|
* providers:
|
|
* openai:
|
|
* apiKeyEnv: OPENAI_API_KEY
|
|
* retryPolicy:
|
|
* mode: normal
|
|
* maxRetries: 2
|
|
* anthropic:
|
|
* apiKeyEnv: ANTHROPIC_API_KEY
|
|
* openrouter:
|
|
* apiKeyEnv: OPENROUTER_API_KEY
|
|
* baseURL: https://proxy.example.com/v1
|
|
* ```
|
|
*
|
|
* @module @deepseek-ai/dsh-llm-pi-ai
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
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 { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import { PiAiAdapter } from './adapter.ts'
|
|
import { Config, resolveProfiles } from './config.ts'
|
|
import type { ResolvedPiAiProviderProfile } from './config.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 const name = 'llm-pi-ai'
|
|
export const inject = ['llm']
|
|
|
|
const NS = settingsNamespace('llm-pi-ai')
|
|
|
|
/**
|
|
* The registry captures these per route; a change here must re-register.
|
|
* Sorted by provider so a settings document that merely reorders its keys is
|
|
* not mistaken for a route change.
|
|
*/
|
|
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
|
|
return [...profiles.entries()]
|
|
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
|
|
.sort((left, right) => left.provider.localeCompare(right.provider))
|
|
}
|
|
|
|
/** 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
|
|
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
|
|
}
|
|
}
|
|
profiles()
|
|
|
|
const resolveApiKey = async (
|
|
provider: string,
|
|
profile: ResolvedPiAiProviderProfile,
|
|
): Promise<string | undefined> => {
|
|
if (profile.apiKey !== undefined) return profile.apiKey
|
|
const ref = profile.apiKeyEnv
|
|
// Only a profile that names no credential at all defers to pi-ai's
|
|
// provider-native discovery. Once one is named, a miss must fail loud:
|
|
// handing pi-ai `undefined` would let it pick up an unrelated ambient key
|
|
// (OPENAI_API_KEY and friends), billing another tenant for a request the
|
|
// deployment meant to authenticate differently.
|
|
if (ref === undefined) return undefined
|
|
const credentials = ctx.get('credentials')
|
|
const hit = credentials !== undefined
|
|
? (await credentials.resolve(ref))?.value
|
|
// Without the seam, read exactly the named variable so a plain
|
|
// cordis.yml composition works from the environment alone.
|
|
: process.env[ref]
|
|
if (hit !== undefined && hit.length > 0) return hit
|
|
throw new LlmError(
|
|
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
|
|
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
|
|
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',
|
|
'MISSING_CREDENTIAL',
|
|
)
|
|
}
|
|
|
|
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],
|
|
})))
|
|
// 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
|
|
// settings section supplies profiles, and routes drop when it empties.
|
|
let registration: AdapterRegistrationHandle | undefined
|
|
let registeredFacts: unknown
|
|
const ensureRegistrationFacts = (): void => {
|
|
const facts = registrationFacts(profiles())
|
|
if (deepEqualJson(facts, registeredFacts)) return
|
|
// The registry captures the route set and each route's retry policy at
|
|
// registration, so a change to either must re-register. The swap is
|
|
// atomic (same adapter instance, validated before anything moves): a
|
|
// conflicting route leaves the previous routes serving requests, and
|
|
// `registeredFacts` only advances once the registry actually holds the
|
|
// new set — so returning to a working configuration always re-applies.
|
|
const routes = [...profiles().keys()]
|
|
if (registration === undefined) {
|
|
// Dormant bare mount: nothing is registered until a section supplies
|
|
// profiles, and an empty section keeps it that way.
|
|
if (routes.length === 0) {
|
|
registeredFacts = facts
|
|
return
|
|
}
|
|
registration = ctx.llm.registerAdapter(routes, adapter)
|
|
} else {
|
|
registration.replace(routes)
|
|
}
|
|
registeredFacts = facts
|
|
}
|
|
ensureRegistrationFacts()
|
|
|
|
installSettingsSection(ctx, NS, Config, config, {
|
|
setSource: (source) => {
|
|
current = source
|
|
},
|
|
onChange: ensureRegistrationFacts,
|
|
})
|
|
}
|