/** * Generic pi-ai-backed implementation of the Harness LLM seam. * * @module dsh-llm-pi-ai/adapter */ import { getModels, streamSimple, } from '@earendil-works/pi-ai' import type { Api, KnownProvider, Model, SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, 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 { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' /** Constructor options for {@link PiAiAdapter}. */ export interface PiAiAdapterOptions { /** Validated provider profiles this adapter instance owns. */ profiles: readonly PiAiProviderProfile[] } /** * Resolve a catalog model dynamically and apply only the configured endpoint * override, preserving the catalog's API/capability/compatibility metadata. */ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model { const model = getModels(profile.provider as KnownProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') } return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL } } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { return { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, ...profile.transport === undefined ? {} : { transport: profile.transport }, ...profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }, ...profile.websocketConnectTimeoutMs === undefined ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }, // The agent recovery layer owns visible attempts; one adapter call is one SDK attempt. maxRetries: 0, } } /** Merge deployment headers while removing case-insensitive attribution collisions. */ function requestHeaders(headers: Readonly> | undefined): Record { const attribution = attributionHeaders() const reserved = new Set(Object.keys(attribution).map(name => name.toLowerCase())) return { ...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))), ...attribution, } } /** * pi-ai-backed multi-provider adapter. Model descriptors are resolved for each * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { private readonly profiles: ReadonlyMap constructor(options: PiAiAdapterOptions) { super() this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override listModels(provider: string): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({ provider, id: model.id, name: model.name, }))) } override resolveModelContext( provider: string, model: string, ): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER', )) } return Promise.resolve().then(() => ({ contextWindow: resolveModel(profile, model).contextWindow, })) } async * stream(options: GenerateOptions): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } const profile = this.profiles.get(options.provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolveModel(profile, options.model) const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) const streamIdleTimeoutMs = profile.streamIdleTimeoutMs using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { const events = streamSimple(model, toPiContext(options), { ...profileOptions(profile), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, signal: watchdog.signal, // Profile headers are deployment-owned; attribution names are // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) const iterator = toStreamChunks(events, model.contextWindow)[Symbol.asyncIterator]() let exhausted = false try { while (true) { const result = await watchdog.next(iterator) const timeout = timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') if (timeout !== undefined) throw timeout if (result.done) { exhausted = true return } yield result.value } } finally { if (!exhausted) { consumer.abort('pi-ai stream consumer stopped') try { await iterator.return(undefined) } catch (_abortedSdkTeardown) { // The stable signal already owns SDK termination; return-time abort cannot add an outcome. } } } } catch (error: unknown) { if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) { throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }) } if (options.signal?.aborted) { throw new LlmError('pi-ai request aborted by caller', 'ABORTED', { cause: error }) } throw error } finally { consumer.abort('pi-ai stream consumer stopped') } } }