소스 검색

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.
Yichen Jiang 1 개월 전
부모
커밋
c0426142c5

+ 4 - 0
packages/llm/llm-pi-ai/package.json

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

+ 24 - 17
packages/llm/llm-pi-ai/src/adapter.ts

@@ -30,15 +30,20 @@ import type {
   StreamChunk,
 } from '@deepseek-ai/dsh-llm'
 import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
-import { resolveProfiles } from './config.ts'
-import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
+import type { ResolvedPiAiProviderProfile } from './config.ts'
 import { toPiContext } from './context.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 {
-  /** Validated provider profiles this adapter instance owns. */
-  profiles: readonly PiAiProviderProfile[]
+  /** 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.
+   */
+  resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
 }
 
 /**
@@ -46,7 +51,7 @@ export interface PiAiAdapterOptions {
  * override, preserving the catalog's API/capability/compatibility metadata.
  */
 function resolvePiModel(
-  profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
+  profile: ResolvedPiAiProviderProfile,
   modelId: string,
 ): Model<Api> {
   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. */
 function profileOptions(
-  profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
+  profile: ResolvedPiAiProviderProfile,
   reasoning: ModelThinkingLevel | undefined,
+  apiKey: string | undefined,
 ): SimpleStreamOptions {
   const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
   return {
-    ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
+    ...apiKey === undefined ? {} : { apiKey },
     ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
     ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
     ...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.
  */
 export class PiAiAdapter extends LlmAdapter {
-  private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
-
-  constructor(options: PiAiAdapterOptions) {
+  constructor(private readonly config: PiAiAdapterOptions) {
     super()
-    this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
   }
 
   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[]> {
-    const profile = this.profiles.get(provider)
+    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'))
     }
@@ -132,7 +135,7 @@ export class PiAiAdapter extends LlmAdapter {
     model: string,
     _signal?: AbortSignal,
   ): Promise<LlmResolvedModelInfo> {
-    const profile = this.profiles.get(provider)
+    const profile = this.config.profiles().get(provider)
     if (profile === undefined) {
       return Promise.reject(new LlmError(
         `pi-ai adapter does not own provider "${provider}"`,
@@ -165,7 +168,10 @@ export class PiAiAdapter extends LlmAdapter {
     if (options.stop !== undefined) {
       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) {
       throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
     }
@@ -174,6 +180,7 @@ export class PiAiAdapter extends LlmAdapter {
       model,
       options.reasoningEffort ?? profile.reasoning,
     )
+    const apiKey = await this.config.resolveApiKey(profile)
 
     const consumer = new AbortController()
     const upstream = options.signal === undefined
@@ -184,7 +191,7 @@ export class PiAiAdapter extends LlmAdapter {
 
     try {
       const events = streamSimple(model, toPiContext(options), {
-        ...profileOptions(profile, reasoning),
+        ...profileOptions(profile, reasoning, apiKey),
         ...options.temperature === undefined ? {} : { temperature: options.temperature },
         ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
         ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },

+ 50 - 35
packages/llm/llm-pi-ai/src/config.ts

@@ -1,5 +1,7 @@
 /**
  * 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
  */
@@ -7,6 +9,8 @@
 import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
 import type { CacheRetention, ModelThinkingLevel, 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'
@@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll
 /** Default maximum idle interval while an adapter stream read is outstanding. */
 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 {
-  /** pi-ai provider catalog name and Harness route key. */
-  provider: string
-  /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */
+  /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
   apiKey?: string
+  /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
+  apiKeyEnv?: string
   /** Override the selected catalog model's endpoint without changing its protocol metadata. */
   baseURL?: string
   /** Provider request headers; Harness attribution wins reserved names. */
@@ -42,18 +46,22 @@ export interface PiAiProviderProfile {
   retryPolicy?: RetryPolicyConfig
 }
 
-/** Validated profile with every adapter-owned default resolved. */
-export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'retryPolicy'> {
+/** 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). */
+  provider: 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
 }
 
-/** Plugin configuration: the non-empty provider profiles this instance owns. */
+/** Plugin configuration: the non-empty provider routes this instance owns. */
 export interface Config {
-  /** Non-empty set of pi-ai provider routes this adapter instance owns. */
-  providers: PiAiProviderProfile[]
+  /** Non-empty dict of pi-ai provider routes, keyed by provider. */
+  providers: Record<string, PiAiProviderProfile>
 }
 
 const thinkingBudgets = z.object({
@@ -64,8 +72,8 @@ const thinkingBudgets = z.object({
 })
 
 const profile = z.object({
-  provider: z.string().required(),
-  apiKey: z.string(),
+  apiKey: z.string().role('secret'),
+  apiKeyEnv: z.string(),
   baseURL: z.string(),
   headers: z.dict(z.string()),
   reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -80,54 +88,61 @@ const profile = z.object({
 
 /** Runtime schema for {@link Config}. */
 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
- * shallow copy suitable for adapter construction.
- * @param profiles - configured provider profiles.
+ * route-keyed map suitable for per-request reads.
+ * @param providers - configured provider profiles keyed by route.
  * @returns validated profiles in configuration order.
  */
-export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
-  if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
+export function resolveProfiles(providers: Readonly<Record<string, PiAiProviderProfile>>): Map<string, ResolvedPiAiProviderProfile> {
+  if (Array.isArray(providers)) {
+    throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
+  }
+  const entries = Object.entries(providers)
+  if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
   const supported = new Set<string>(getBuiltinProviders())
-  const seen = new Set<string>()
-  return profiles.map((source) => {
+  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')
     }
-    if (source.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 (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
+    if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
+    if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
     if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
-      throw new Error(`llm-pi-ai: provider "${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) {
-      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
     if (!Number.isFinite(streamIdleTimeoutMs)
       || streamIdleTimeoutMs <= 0
       || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
       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)
-    return {
-      ...source,
+    const { apiKeyEnv, retryPolicy, ...rest } = source
+    resolved.set(provider, {
+      ...rest,
+      provider,
+      ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
       streamIdleTimeoutMs,
-      retryPolicy: resolveRetryPolicy(
-        source.retryPolicy,
-        `llm-pi-ai: provider "${source.provider}" retryPolicy`,
-      ),
-      ...source.headers === undefined ? {} : { headers: { ...source.headers } },
-      ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } },
-    }
-  })
+      retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
+      ...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
+      ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
+    })
+  }
+  return resolved
 }

+ 91 - 13
packages/llm/llm-pi-ai/src/index.ts

@@ -1,22 +1,27 @@
 /**
- * Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an
- * explicit set of provider profiles; requests select a profile by provider and
- * resolve the model dynamically from pi-ai's installed catalog.
+ * 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:
- *       - provider: openai
- *         apiKey: !!js process.env.OPENAI_API_KEY
+ *       openai:
+ *         apiKeyEnv: OPENAI_API_KEY
  *         retryPolicy:
  *           mode: normal
  *           maxRetries: 2
- *       - provider: anthropic
- *         apiKey: !!js process.env.ANTHROPIC_API_KEY
- *       - provider: openrouter
- *         apiKey: !!js process.env.OPENROUTER_API_KEY
+ *       anthropic:
+ *         apiKeyEnv: ANTHROPIC_API_KEY
+ *       openrouter:
+ *         apiKeyEnv: OPENROUTER_API_KEY
  *         baseURL: https://proxy.example.com/v1
  * ```
  *
@@ -25,20 +30,93 @@
 
 import type { Context } from 'cordis'
 import type {} from '@deepseek-ai/dsh-llm'
+import { deepEqualJson, 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 } 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. */
+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. */
 export function apply(ctx: Context, config: Config): void {
-  const profiles = resolveProfiles(config.providers)
-  const adapter = new PiAiAdapter({ profiles: config.providers })
-  ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter)
+  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 (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()
+    })
+  })
 }

+ 7 - 6
packages/llm/llm-pi-ai/tests/adapter.e2e.ts

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

+ 77 - 126
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -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 { Context } from 'cordis'
 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 { resolveProfiles } from '../src/config.ts'
 import { assemble } from './assemble.ts'
-
-interface MockServer {
-  url: string
-  paths: string[]
-  requests: unknown[]
-  headers: IncomingMessage['headers'][]
-  readonly closedResponses: number
-  responseClosed: Promise<void>
-}
-
-const servers: Server[] = []
+import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 
 afterEach(async () => {
   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> {
   const ctx = new Context()
   await ctx.plugin(LlmService)
   await ctx.plugin(LlmPiAi, {
-    providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }],
+    providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
   })
   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', () => {
   it('resolves a catalog model dynamically and uses a private endpoint', async () => {
     const server = await mockServer([{ events: textEvents }])
@@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => {
     const server = await mockServer([{ events: textEvents }])
     const ctx = new Context()
     await ctx.plugin(LlmService)
-    ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({
-      profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }],
+    ctx.llm.registerAdapter(['deepseek'], adapterOf({
+      deepseek: { apiKey: 'test-key', baseURL: server.url },
     }))
 
     const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     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: [] })
     expect(result.finish.kind).toBe('error')
@@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     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: [] })
@@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     await ctx.plugin(LlmPiAi, {
-      providers: [{
-        provider: 'openai',
-        apiKey: 'test-key',
-        baseURL: `${server.url}/api/projects/openai/openai/v1`,
-        headers: { 'api-key': 'test-key', Authorization: '' },
-      }],
+      providers: {
+        openai: {
+          apiKey: 'test-key',
+          baseURL: `${server.url}/api/projects/openai/openai/v1`,
+          headers: { 'api-key': 'test-key', Authorization: '' },
+        },
+      },
     })
     const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
     expect(result.finish.kind).toBe('error')
@@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     const fiber = await ctx.plugin(LlmPiAi, {
-      providers: [
-        {
-          provider: 'openai',
+      providers: {
+        openai: {
           retryPolicy: {
             mode: 'always',
             backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
           },
         },
-        { provider: 'anthropic' },
-      ],
+        anthropic: {},
+      },
     })
     expect(ctx.llm.listProviders()).toEqual([
       { 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 () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
-    await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] })
+    await ctx.plugin(LlmPiAi, { providers: { openai: {} } })
     const models = await ctx.llm.listModels('openai')
     expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
       provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
@@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     await ctx.plugin(LlmPiAi, {
-      providers: [{ provider: 'deepseek' }, { provider: 'openai' }],
+      providers: { deepseek: {}, openai: {} },
     })
 
     await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
@@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => {
     const supported = new Context()
     await supported.plugin(LlmService)
     await supported.plugin(LlmPiAi, {
-      providers: [{ provider: 'deepseek', reasoning: 'max' }],
+      providers: { deepseek: { reasoning: 'max' } },
     })
     await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
       .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
@@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => {
     const unsupported = new Context()
     await unsupported.plugin(LlmService)
     await unsupported.plugin(LlmPiAi, {
-      providers: [{ provider: 'deepseek', reasoning: 'medium' }],
+      providers: { deepseek: { reasoning: 'medium' } },
     })
     await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
       .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
@@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => {
     const disabled = new Context()
     await disabled.plugin(LlmService)
     await disabled.plugin(LlmPiAi, {
-      providers: [{ provider: 'deepseek', reasoning: 'off' }],
+      providers: { deepseek: { reasoning: 'off' } },
     })
     await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
       .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
@@ -443,24 +377,45 @@ describe('provider profile lifecycle', () => {
     expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
   })
 
-  it('validates empty, duplicate, unknown, and explicitly blank profiles', () => {
-    expect(() => resolveProfiles([])).toThrow(/at least one/)
-    expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/)
-    expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/)
-    expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/)
-    expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/)
-    expect(() => resolveProfiles([{ provider: 'openai', apiKey: '  ' }])).toThrow(/empty apiKey/)
-    expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
+  it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => {
+    vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-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 custom-ref-key')
+  })
+
+  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)(
     'rejects removed profile field %s instead of silently restoring hidden SDK retries',
     async (field) => {
-      const legacy = { provider: 'openai', [field]: 2 }
-      expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i)
+      const legacy = { [field]: 2 }
+      expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i)
       const ctx = new Context()
       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)
     },
   )
@@ -476,30 +431,26 @@ describe('provider profile lifecycle', () => {
     for (const entry of invalid) {
       const ctx = new Context()
       await ctx.plugin(LlmService)
-      await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] }))
+      await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } }))
         .rejects.toThrow()
     }
   })
 
   it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
-    expect(() => resolveProfiles([{
-      provider: 'openai',
-      retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } },
-    }])).toThrow(/retryPolicy\.backoff\.jitterRatio/)
+    expect(() => resolveProfiles({
+      openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } },
+    })).toThrow(/retryPolicy\.backoff\.jitterRatio/)
 
     const ctx = new Context()
     await ctx.plugin(LlmService)
     await expect(ctx.plugin(LlmPiAi, {
-      providers: [{
-        provider: 'openai',
-        retryPolicy: { mode: 'normal', maxRetries: -1 },
-      }],
+      providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } },
     })).rejects.toThrow(/retryPolicy/)
     expect(ctx.llm.listProviders()).toEqual([])
   })
 
   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.resolveModel('anthropic', 'claude-sonnet-4'))
       .rejects.toMatchObject({ code: 'NO_ADAPTER' })
@@ -511,12 +462,12 @@ describe('provider profile lifecycle', () => {
     expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
   })
 
-  it('validates direct-constructor profiles at the embedding boundary', () => {
-    expect(() => new PiAiAdapter({
-      profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }],
+  it('validates profiles at the shared resolver boundary', () => {
+    expect(() => resolveProfiles({
+      openai: { streamIdleTimeoutMs: 0 },
     })).toThrow(/streamIdleTimeoutMs.*positive finite/)
-    expect(() => new PiAiAdapter({
-      profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }],
+    expect(() => resolveProfiles({
+      openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
     })).toThrow(/streamIdleTimeoutMs.*no greater/)
   })
 })
@@ -527,7 +478,7 @@ describe('abort wiring', () => {
     const message = Object.defineProperty({}, 'role', {
       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> => {
       for await (const _chunk of adapter.stream({
         provider: 'deepseek',
@@ -548,7 +499,7 @@ describe('abort wiring', () => {
         throw original
       },
     })
-    const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
+    const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
     const drain = async (): Promise<void> => {
       for await (const _chunk of adapter.stream({
         provider: 'deepseek',
@@ -562,7 +513,7 @@ describe('abort wiring', () => {
   })
 
   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()
     controller.abort('already stopped')
     const chunks = []

+ 116 - 0
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts

@@ -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'])
+  })
+})

+ 82 - 0
packages/llm/llm-pi-ai/tests/mock-server.ts

@@ -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 },
+  }
+}

+ 2 - 3
packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts

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

+ 5 - 1
packages/llm/llm-pi-ai/tests/sdk-options.spec.ts

@@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
 })
 
 import { PiAiAdapter } from '../src/adapter.ts'
+import { resolveProfiles } from '../src/config.ts'
 
 afterEach(() => { streamSimple.mockReset() })
 
@@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
         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> => {
       for await (const _chunk of adapter.stream({
         provider: 'openai',

+ 6 - 0
packages/llm/llm-pi-ai/tsconfig.json

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

+ 6 - 0
pnpm-lock.yaml

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