feat(llm-pi-ai): route-keyed profiles with per-request resolution and in-place route swaps

providers becomes a dict keyed by provider route, so the composition base
and the llm-pi-ai settings section merge per provider and the route set is
structural; the pre-release array shape and per-profile provider field fail
loud with migration directions. The adapter reads a profiles thunk once per
operation and resolves the credential per stream call (literal apiKey, then
apiKeyEnv through ctx.credentials with an ambient env fallback, then pi-ai's
provider-native discovery), so key, endpoint, and knob changes reach the
next request without restarts. Route-set or captured-retry-policy changes
re-register the same adapter instance in one synchronous section; an invalid
settings snapshot keeps the last good profiles.
This commit is contained in:
Yichen Jiang
2026-07-29 13:35:38 +08:00
parent f05ab3f945
commit c0426142c5
12 changed files with 470 additions and 201 deletions

View File

@@ -27,8 +27,10 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
}, },
@@ -37,9 +39,11 @@
"schemastery": "^3.18.0" "schemastery": "^3.18.0"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
} }

View File

@@ -30,15 +30,20 @@ import type {
StreamChunk, StreamChunk,
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts' import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts' import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}. */ /** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
export interface PiAiAdapterOptions { export interface PiAiAdapterOptions {
/** Validated provider profiles this adapter instance owns. */ /** Current validated profiles by provider route; called once per operation. */
profiles: readonly PiAiProviderProfile[] 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.
*/
resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
} }
/** /**
@@ -46,7 +51,7 @@ export interface PiAiAdapterOptions {
* override, preserving the catalog's API/capability/compatibility metadata. * override, preserving the catalog's API/capability/compatibility metadata.
*/ */
function resolvePiModel( function resolvePiModel(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>, profile: ResolvedPiAiProviderProfile,
modelId: string, modelId: string,
): Model<Api> { ): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
@@ -58,12 +63,13 @@ function resolvePiModel(
/** Copy profile stream knobs into pi-ai's common option vocabulary. */ /** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions( function profileOptions(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>, profile: ResolvedPiAiProviderProfile,
reasoning: ModelThinkingLevel | undefined, reasoning: ModelThinkingLevel | undefined,
apiKey: string | undefined,
): SimpleStreamOptions { ): SimpleStreamOptions {
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return { return {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...apiKey === undefined ? {} : { apiKey },
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
@@ -104,19 +110,16 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
* request, so models need not be registered during the Cordis lifecycle. * request, so models need not be registered during the Cordis lifecycle.
*/ */
export class PiAiAdapter extends LlmAdapter { export class PiAiAdapter extends LlmAdapter {
private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile> constructor(private readonly config: PiAiAdapterOptions) {
constructor(options: PiAiAdapterOptions) {
super() super()
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
} }
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
return this.profiles.get(provider)?.retryPolicy return this.config.profiles().get(provider)?.retryPolicy
} }
override listModels(provider: string): Promise<readonly LlmModelInfo[]> { override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.profiles.get(provider) const profile = this.config.profiles().get(provider)
if (profile === undefined) { if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
} }
@@ -132,7 +135,7 @@ export class PiAiAdapter extends LlmAdapter {
model: string, model: string,
_signal?: AbortSignal, _signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> { ): Promise<LlmResolvedModelInfo> {
const profile = this.profiles.get(provider) const profile = this.config.profiles().get(provider)
if (profile === undefined) { if (profile === undefined) {
return Promise.reject(new LlmError( return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`, `pi-ai adapter does not own provider "${provider}"`,
@@ -165,7 +168,10 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) { if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
} }
const profile = this.profiles.get(options.provider) // 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) { if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
} }
@@ -174,6 +180,7 @@ export class PiAiAdapter extends LlmAdapter {
model, model,
options.reasoningEffort ?? profile.reasoning, options.reasoningEffort ?? profile.reasoning,
) )
const apiKey = await this.config.resolveApiKey(profile)
const consumer = new AbortController() const consumer = new AbortController()
const upstream = options.signal === undefined const upstream = options.signal === undefined
@@ -184,7 +191,7 @@ export class PiAiAdapter extends LlmAdapter {
try { try {
const events = streamSimple(model, toPiContext(options), { const events = streamSimple(model, toPiContext(options), {
...profileOptions(profile, reasoning), ...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },

View File

@@ -1,5 +1,7 @@
/** /**
* Configuration schema and provider-profile validation for the pi-ai adapter. * 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.
* *
* @module dsh-llm-pi-ai/config * @module dsh-llm-pi-ai/config
*/ */
@@ -7,6 +9,8 @@
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery' 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
@@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll
/** Default maximum idle interval while an adapter stream read is outstanding. */ /** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Configuration for one pi-ai provider route. */ /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile { export interface PiAiProviderProfile {
/** pi-ai provider catalog name and Harness route key. */ /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
provider: string
/** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */
apiKey?: string 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. */ /** Override the selected catalog model's endpoint without changing its protocol metadata. */
baseURL?: string baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */ /** Provider request headers; Harness attribution wins reserved names. */
@@ -42,18 +46,22 @@ export interface PiAiProviderProfile {
retryPolicy?: RetryPolicyConfig retryPolicy?: RetryPolicyConfig
} }
/** Validated profile with every adapter-owned default resolved. */ /** Validated profile with its route stamped and every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'retryPolicy'> { export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy'> {
/** pi-ai provider catalog name and Harness route key (the configuration dict key). */
provider: string
/** Validated credential reference, when one is configured. */
apiKeyEnv?: CredentialRef
/** Positive finite provider-idle interval after defaulting. */ /** Positive finite provider-idle interval after defaulting. */
streamIdleTimeoutMs: number streamIdleTimeoutMs: number
/** Immutable retry policy captured with this provider route. */ /** Immutable retry policy captured with this provider route. */
retryPolicy: ResolvedRetryPolicy retryPolicy: ResolvedRetryPolicy
} }
/** Plugin configuration: the non-empty provider profiles this instance owns. */ /** Plugin configuration: the non-empty provider routes this instance owns. */
export interface Config { export interface Config {
/** Non-empty set of pi-ai provider routes this adapter instance owns. */ /** Non-empty dict of pi-ai provider routes, keyed by provider. */
providers: PiAiProviderProfile[] providers: Record<string, PiAiProviderProfile>
} }
const thinkingBudgets = z.object({ const thinkingBudgets = z.object({
@@ -64,8 +72,8 @@ const thinkingBudgets = z.object({
}) })
const profile = z.object({ const profile = z.object({
provider: z.string().required(), apiKey: z.string().role('secret'),
apiKey: z.string(), apiKeyEnv: z.string(),
baseURL: z.string(), baseURL: z.string(),
headers: z.dict(z.string()), headers: z.dict(z.string()),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -80,54 +88,61 @@ const profile = z.object({
/** Runtime schema for {@link Config}. */ /** Runtime schema for {@link Config}. */
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
providers: z.array(profile).required(), providers: z.dict(profile).required(),
}) })
/** /**
* Validate profiles against the installed pi-ai catalog and return a detached * Validate profiles against the installed pi-ai catalog and return a detached
* shallow copy suitable for adapter construction. * route-keyed map suitable for per-request reads.
* @param profiles - configured provider profiles. * @param providers - configured provider profiles keyed by route.
* @returns validated profiles in configuration order. * @returns validated profiles in configuration order.
*/ */
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { export function resolveProfiles(providers: Readonly<Record<string, PiAiProviderProfile>>): Map<string, ResolvedPiAiProviderProfile> {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') 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)
if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
const supported = new Set<string>(getBuiltinProviders()) const supported = new Set<string>(getBuiltinProviders())
const seen = new Set<string>() const resolved = new Map<string, ResolvedPiAiProviderProfile>()
return profiles.map((source) => { for (const [provider, source] of entries) {
const legacy = source as PiAiProviderProfile & { const legacy = source as PiAiProviderProfile & {
provider?: unknown
maxRetries?: unknown maxRetries?: unknown
maxRetryDelayMs?: 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) { 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') throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
} }
if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`) 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) { if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
} }
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs) if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs <= 0
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error( throw new Error(
`llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
) )
} }
seen.add(source.provider) const { apiKeyEnv, retryPolicy, ...rest } = source
return { resolved.set(provider, {
...source, ...rest,
provider,
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
streamIdleTimeoutMs, streamIdleTimeoutMs,
retryPolicy: resolveRetryPolicy( retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
source.retryPolicy, ...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
`llm-pi-ai: provider "${source.provider}" retryPolicy`, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
), })
...source.headers === undefined ? {} : { headers: { ...source.headers } }, }
...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, return resolved
}
})
} }

View File

@@ -1,22 +1,27 @@
/** /**
* Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
* explicit set of provider profiles; requests select a profile by provider and * provider routes; requests select a profile by provider and resolve the
* resolve the model dynamically from pi-ai's installed catalog. * 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 * ```yaml
* - id: llm * - id: llm
* name: '@deepseek-ai/dsh-llm-pi-ai' * name: '@deepseek-ai/dsh-llm-pi-ai'
* config: * config:
* providers: * providers:
* - provider: openai * openai:
* apiKey: !!js process.env.OPENAI_API_KEY * apiKeyEnv: OPENAI_API_KEY
* retryPolicy: * retryPolicy:
* mode: normal * mode: normal
* maxRetries: 2 * maxRetries: 2
* - provider: anthropic * anthropic:
* apiKey: !!js process.env.ANTHROPIC_API_KEY * apiKeyEnv: ANTHROPIC_API_KEY
* - provider: openrouter * openrouter:
* apiKey: !!js process.env.OPENROUTER_API_KEY * apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1 * baseURL: https://proxy.example.com/v1
* ``` * ```
* *
@@ -25,20 +30,93 @@
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm'
import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts' import { PiAiAdapter } from './adapter.ts'
import { Config, resolveProfiles } from './config.ts' import { Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
export { PiAiAdapter } from './adapter.ts' export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts'
export { Config } from './config.ts' export { Config } from './config.ts'
export type { PiAiProviderProfile } from './config.ts' export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export const name = 'llm-pi-ai' export const name = 'llm-pi-ai'
export const inject = ['llm'] export const inject = ['llm']
const NS = settingsNamespace('llm-pi-ai')
/** The registry captures these per route; a change here must re-register. */
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
}
/** Register one generic pi-ai adapter for all configured provider routes. */ /** Register one generic pi-ai adapter for all configured provider routes. */
export function apply(ctx: Context, config: Config): void { export function apply(ctx: Context, config: Config): void {
const profiles = resolveProfiles(config.providers) let current: () => Config = () => config
const adapter = new PiAiAdapter({ profiles: config.providers }) let lastRaw: Config | undefined
ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) 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 (profile: ResolvedPiAiProviderProfile): Promise<string | undefined> => {
if (profile.apiKey !== undefined) return profile.apiKey
const ref = profile.apiKeyEnv
if (ref === undefined) return undefined
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(ref))?.value
// Without the seam, keep an ambient fallback so a plain cordis.yml
// composition works from the environment alone; an empty variable defers
// to pi-ai's own provider-native discovery like an absent one.
const ambient = process.env[ref]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
}
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below.
let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
let registeredFacts = registrationFacts(profiles())
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: swap the registration in one synchronous section (same
// adapter instance, no NO_ADAPTER window).
disposeRoutes()
disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
registeredFacts = facts
}
ctx.inject(['settings'], (sctx) => {
const scope = sctx.settings.register(NS, Config, { base: config })
current = () => scope.get()
sctx.effect(() => () => {
// Settings detached (provider disposed or reloading): fall back to the
// composition entry so the plugin keeps working exactly as configured.
current = () => config
ensureRegistrationFacts()
})
ensureRegistrationFacts()
scope.watch(() => {
ensureRegistrationFacts()
})
})
} }

View File

@@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial<PiAiProviderProfile> = {}
contexts.push(ctx) contexts.push(ctx)
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ providers: {
provider: 'deepseek', deepseek: {
...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
...config, ...config,
}], },
},
}) })
return ctx return ctx
} }

View File

@@ -1,5 +1,3 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
@@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { resolveProfiles } from '../src/config.ts' import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts' import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
interface MockServer {
url: string
paths: string[]
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
responseClosed: Promise<void>
}
const servers: Server[] = []
afterEach(async () => { afterEach(async () => {
vi.unstubAllEnvs() vi.unstubAllEnvs()
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) await closeMockServers()
}) })
async function mockServer(script: {
status?: number
events?: string[]
body?: string
delayMs?: number
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.on('close', () => {
closedResponses += 1
responseClosed.resolve(undefined)
})
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
paths.push(request.url ?? '')
requests.push(body.length === 0 ? undefined : JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
response.end(behavior.body ?? '{}')
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
let index = 0
const writeNext = (): void => {
const event = behavior.events?.[index++]
if (event === undefined) { response.end(); return }
response.write(`data: ${event}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else setTimeout(writeNext, behavior.delayMs)
}
writeNext()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
paths,
requests,
headers,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> { async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }], providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
}) })
return ctx return ctx
} }
/** Direct adapter over the real profile resolver, with literal-key resolution. */
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: profile => Promise.resolve(profile.apiKey),
})
}
describe('PiAiAdapter provider routing', () => { describe('PiAiAdapter provider routing', () => {
it('resolves a catalog model dynamically and uses a private endpoint', async () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => {
const server = await mockServer([{ events: textEvents }]) const server = await mockServer([{ events: textEvents }])
@@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => {
const server = await mockServer([{ events: textEvents }]) const server = await mockServer([{ events: textEvents }])
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ ctx.llm.registerAdapter(['deepseek'], adapterOf({
profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }], deepseek: { apiKey: 'test-key', baseURL: server.url },
})) }))
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
}) })
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error') expect(result.finish.kind).toBe('error')
@@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
}) })
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
@@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ providers: {
provider: 'openai', openai: {
apiKey: 'test-key', apiKey: 'test-key',
baseURL: `${server.url}/api/projects/openai/openai/v1`, baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' }, headers: { 'api-key': 'test-key', Authorization: '' },
}], },
},
}) })
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
expect(result.finish.kind).toBe('error') expect(result.finish.kind).toBe('error')
@@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmPiAi, { const fiber = await ctx.plugin(LlmPiAi, {
providers: [ providers: {
{ openai: {
provider: 'openai',
retryPolicy: { retryPolicy: {
mode: 'always', mode: 'always',
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
}, },
}, },
{ provider: 'anthropic' }, anthropic: {},
], },
}) })
expect(ctx.llm.listProviders()).toEqual([ expect(ctx.llm.listProviders()).toEqual([
{ id: 'openai', name: 'openai' }, { id: 'openai', name: 'openai' },
@@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => {
it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => { it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] }) await ctx.plugin(LlmPiAi, { providers: { openai: {} } })
const models = await ctx.llm.listModels('openai') const models = await ctx.llm.listModels('openai')
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
@@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek' }, { provider: 'openai' }], providers: { deepseek: {}, openai: {} },
}) })
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
@@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => {
const supported = new Context() const supported = new Context()
await supported.plugin(LlmService) await supported.plugin(LlmService)
await supported.plugin(LlmPiAi, { await supported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'max' }], providers: { deepseek: { reasoning: 'max' } },
}) })
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
@@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => {
const unsupported = new Context() const unsupported = new Context()
await unsupported.plugin(LlmService) await unsupported.plugin(LlmService)
await unsupported.plugin(LlmPiAi, { await unsupported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'medium' }], providers: { deepseek: { reasoning: 'medium' } },
}) })
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
@@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => {
const disabled = new Context() const disabled = new Context()
await disabled.plugin(LlmService) await disabled.plugin(LlmService)
await disabled.plugin(LlmPiAi, { await disabled.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'off' }], providers: { deepseek: { reasoning: 'off' } },
}) })
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
@@ -443,24 +377,45 @@ describe('provider profile lifecycle', () => {
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
}) })
it('validates empty, duplicate, unknown, and explicitly blank profiles', () => { it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => {
expect(() => resolveProfiles([])).toThrow(/at least one/) vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key')
expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/) const server = await mockServer([{ events: textEvents }])
expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/) expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key')
expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/) })
expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => {
vi.stubEnv('PI_CUSTOM_REF_KEY', '')
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
expect(() => resolveProfiles({})).toThrow(/at least one/)
expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
// The pre-release array shape and its per-profile provider field fail
// loud with migration directions instead of half-working.
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
}) })
it.each(['maxRetries', 'maxRetryDelayMs'] as const)( it.each(['maxRetries', 'maxRetryDelayMs'] as const)(
'rejects removed profile field %s instead of silently restoring hidden SDK retries', 'rejects removed profile field %s instead of silently restoring hidden SDK retries',
async (field) => { async (field) => {
const legacy = { provider: 'openai', [field]: 2 } const legacy = { [field]: 2 }
expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i)
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } }))
.rejects.toThrow(/removed.*agent recovery/i) .rejects.toThrow(/removed.*agent recovery/i)
}, },
) )
@@ -476,30 +431,26 @@ describe('provider profile lifecycle', () => {
for (const entry of invalid) { for (const entry of invalid) {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } }))
.rejects.toThrow() .rejects.toThrow()
} }
}) })
it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
expect(() => resolveProfiles([{ expect(() => resolveProfiles({
provider: 'openai', openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } },
retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, })).toThrow(/retryPolicy\.backoff\.jitterRatio/)
}])).toThrow(/retryPolicy\.backoff\.jitterRatio/)
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { await expect(ctx.plugin(LlmPiAi, {
providers: [{ providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } },
provider: 'openai',
retryPolicy: { mode: 'normal', maxRetries: -1 },
}],
})).rejects.toThrow(/retryPolicy/) })).rejects.toThrow(/retryPolicy/)
expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listProviders()).toEqual([])
}) })
it('constructs the adapter directly and rejects routes it does not own', async () => { it('constructs the adapter directly and rejects routes it does not own', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) const adapter = adapterOf({ openai: {} })
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4'))
.rejects.toMatchObject({ code: 'NO_ADAPTER' }) .rejects.toMatchObject({ code: 'NO_ADAPTER' })
@@ -511,12 +462,12 @@ describe('provider profile lifecycle', () => {
expect(new LlmError('x', 'X')).toBeInstanceOf(Error) expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
}) })
it('validates direct-constructor profiles at the embedding boundary', () => { it('validates profiles at the shared resolver boundary', () => {
expect(() => new PiAiAdapter({ expect(() => resolveProfiles({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], openai: { streamIdleTimeoutMs: 0 },
})).toThrow(/streamIdleTimeoutMs.*positive finite/) })).toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => new PiAiAdapter({ expect(() => resolveProfiles({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
})).toThrow(/streamIdleTimeoutMs.*no greater/) })).toThrow(/streamIdleTimeoutMs.*no greater/)
}) })
}) })
@@ -527,7 +478,7 @@ describe('abort wiring', () => {
const message = Object.defineProperty({}, 'role', { const message = Object.defineProperty({}, 'role', {
get() { throw original }, get() { throw original },
}) })
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const drain = async (): Promise<void> => { const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ for await (const _chunk of adapter.stream({
provider: 'deepseek', provider: 'deepseek',
@@ -548,7 +499,7 @@ describe('abort wiring', () => {
throw original throw original
}, },
}) })
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const drain = async (): Promise<void> => { const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ for await (const _chunk of adapter.stream({
provider: 'deepseek', provider: 'deepseek',
@@ -562,7 +513,7 @@ describe('abort wiring', () => {
}) })
it('resolves catalog endpoints without an override before honoring pre-abort', async () => { it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const controller = new AbortController() const controller = new AbortController()
controller.abort('already stopped') controller.abort('already stopped')
const chunks = [] const chunks = []

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const NS = settingsNamespace('llm-pi-ai')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
await closeMockServers()
vi.unstubAllEnvs()
})
async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
/** Real dynamic composition mirroring the deepseek twin's harness. */
async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
})
await ctx.plugin(LlmService)
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(LlmPiAi, config)
return ctx
}
describe('request-level dynamic profiles', () => {
it('adds a provider route from settings and drops it when the user layer resets', async () => {
const dir = await home()
const server = await mockServer([{ events: textEvents }])
const ctx = await boot(dir, {
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await ctx.settings.update(NS, {
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer live-key')
// Reset the user layer: the settings-born route unregisters, the
// composition route stays.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
})
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
})
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer pk-one')
await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two')
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[1]?.authorization).toBe('Bearer pk-two')
})
it('re-registers routes in place when a captured retry policy changes', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {} } })
await ctx.settings.update(NS, {
providers: {
openai: {
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
},
},
})
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
mode: 'always',
initialDelayMs: 25,
maxDelayMs: 100,
jitterRatio: 0.2,
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {} } })
// Schema-valid but catalog-invalid: the resolver rejects it and the
// last good route set keeps serving.
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
})

View File

@@ -0,0 +1,82 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
export interface MockServer {
url: string
paths: string[]
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
responseClosed: Promise<void>
}
const servers: Server[] = []
/** Close every server opened since the last call; run from each spec's afterEach. */
export async function closeMockServers(): Promise<void> {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
}
/** A minimal complete text generation in pi-ai's chat-completions shape. */
export const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
/** Local provider stand-in: replays scripted behaviors per request. */
export async function mockServer(script: {
status?: number
events?: string[]
body?: string
delayMs?: number
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.on('close', () => {
closedResponses += 1
responseClosed.resolve(undefined)
})
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
paths.push(request.url ?? '')
requests.push(body.length === 0 ? undefined : JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
response.end(behavior.body ?? '{}')
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
let index = 0
const writeNext = (): void => {
const event = behavior.events?.[index++]
if (event === undefined) { response.end(); return }
response.write(`data: ${event}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else setTimeout(writeNext, behavior.delayMs)
}
writeNext()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
paths,
requests,
headers,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
}

View File

@@ -43,12 +43,11 @@ async function harness(): Promise<Context> {
contexts.push(ctx) contexts.push(ctx)
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { await ctx.plugin(LlmPiAi, {
providers: providerCases.map(profile => ({ providers: Object.fromEntries(providerCases.map(profile => [profile.provider, {
provider: profile.provider,
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL },
...profile.headers === undefined ? {} : { headers: profile.headers }, ...profile.headers === undefined ? {} : { headers: profile.headers },
})), }])),
}) })
return ctx return ctx
} }

View File

@@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
}) })
import { PiAiAdapter } from '../src/adapter.ts' import { PiAiAdapter } from '../src/adapter.ts'
import { resolveProfiles } from '../src/config.ts'
afterEach(() => { streamSimple.mockReset() }) afterEach(() => { streamSimple.mockReset() })
@@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
throw failure throw failure
}, },
}) })
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
resolveApiKey: () => Promise.resolve('test-key'),
})
const drain = async (): Promise<void> => { const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ for await (const _chunk of adapter.stream({
provider: 'openai', provider: 'openai',

View File

@@ -20,6 +20,12 @@
{ {
"path": "../../llm/llm" "path": "../../llm/llm"
}, },
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
}, },

6
pnpm-lock.yaml generated
View File

@@ -2977,6 +2977,9 @@ importers:
specifier: ^3.18.0 specifier: ^3.18.0
version: 3.18.0 version: 3.18.0
devDependencies: devDependencies:
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../../credentials/credentials
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
@@ -2986,6 +2989,9 @@ importers:
'@deepseek-ai/dsh-llm-deepseek': '@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^ specifier: workspace:^
version: link:../llm-deepseek version: link:../llm-deepseek
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-timeout': '@deepseek-ai/dsh-timeout':
specifier: workspace:^ specifier: workspace:^
version: link:../../util/timeout version: link:../../util/timeout