import { AIError, createAI, type AI, type AIConfig, type GenerateOptions, type Message, } from "./index.ts"; export interface AIUsage { inputTokens?: number; outputTokens?: number; totalTokens?: number; costUsd?: number; } export interface AIResult { value: T; provider: string; model?: string; usage?: AIUsage; finishReason?: string; toolCalls?: AIToolCall[]; raw?: unknown; } export interface AIProviderCapabilities { streaming?: boolean; structuredOutput?: boolean; tools?: boolean; usage?: boolean; } export interface AIToolCall { id: string; name: string; arguments: unknown; } export interface AITool { name: string; description?: string; validate?: (value: unknown) => value is TInput; execute(input: TInput, context: { signal?: AbortSignal }): TOutput | Promise; } export interface AIProvider { name: string; capabilities?: AIProviderCapabilities; generate(prompt: string | Message[], options?: GenerateOptions): Promise>; stream?( prompt: string | Message[], options?: GenerateOptions, ): AsyncGenerator; } export interface AIRetryOptions { attempts?: number; baseDelayMs?: number; maxDelayMs?: number; retry?: (error: unknown) => boolean; } export interface AICircuitBreakerOptions { failureThreshold?: number; resetAfterMs?: number; } export interface AIClientOptions { providers: AIProvider[]; fallback?: boolean; retry?: AIRetryOptions; circuitBreaker?: AICircuitBreakerOptions; /** Receives metadata only; prompts, provider raw responses, and credentials are never emitted. */ onAttempt?: (event: AIAttemptEvent) => void | Promise; } export interface AIAttemptEvent { provider: string; attempt: number; outcome: "start" | "success" | "error" | "circuit-open"; durationMs?: number; error?: { name: string; type?: string; status?: number; message: string }; usage?: AIUsage; } export interface AIClient { generate( prompt: string | Message[], options?: GenerateOptions & { provider?: string }, ): Promise>; generateObject( prompt: string | Message[], options?: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T }, ): Promise>; executeTools( result: AIResult, tools: AITool[], options?: { signal?: AbortSignal }, ): Promise>; stream( prompt: string | Message[], options?: GenerateOptions & { provider?: string }, ): AsyncGenerator; capabilities(provider?: string): Record; } export function anthropicProvider(config: AIConfig = {}): AIProvider { const client = createAI(config); return { name: "anthropic", capabilities: { streaming: true, structuredOutput: true }, async generate(prompt, options) { return { value: await client.generate(prompt, options), provider: "anthropic", model: options?.model ?? config.model, }; }, stream: (prompt, options) => client.stream(prompt, options), }; } export function aiProvider( name: string, client: AI, capabilities: AIProviderCapabilities = {}, ): AIProvider { return { name, capabilities: { streaming: true, ...capabilities }, async generate(prompt, options) { return { value: await client.generate(prompt, options), provider: name, model: options?.model, }; }, stream: (prompt, options) => client.stream(prompt, options), }; } export interface DeterministicAIProviderOptions { name?: string; responses?: Array>; handler?: ( prompt: string | Message[], options?: GenerateOptions, ) => string | AIResult | Promise>; } /** Offline provider for examples and tests. Responses are consumed in order. */ export function deterministicAIProvider(options: DeterministicAIProviderOptions = {}): AIProvider { let index = 0; const name = options.name ?? "deterministic"; return { name, capabilities: { streaming: true, structuredOutput: true, tools: true, usage: true }, async generate(prompt, callOptions) { const selected = options.handler ? await options.handler(prompt, callOptions) : options.responses?.[index++]; if (selected === undefined) throw new AIError("No deterministic response configured", 0, "provider_error"); return typeof selected === "string" ? { value: selected, provider: name } : { ...selected, provider: name }; }, async *stream(prompt, callOptions) { yield (await this.generate(prompt, callOptions)).value; }, }; } function jsonText(value: string): string { const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(value); return (fenced?.[1] ?? value).trim(); } function safeError(error: unknown): AIAttemptEvent["error"] { if (error instanceof AIError) return { name: error.name, type: error.type, status: error.status, message: error.message.slice(0, 300), }; if (error instanceof Error) return { name: error.name, message: error.message.slice(0, 300) }; return { name: "Error", message: "Unknown provider error" }; } function defaultRetry(error: unknown): boolean { return ( error instanceof AIError && (error.status === 408 || error.status === 429 || error.status >= 500) ); } function abortError(): Error { return new DOMException("The operation was aborted", "AbortError"); } async function delay(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) throw signal.reason ?? abortError(); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal?.addEventListener( "abort", () => { clearTimeout(timer); reject(signal.reason ?? abortError()); }, { once: true }, ); }); } export function createAIClient(options: AIClientOptions): AIClient { if (!options.providers.length) throw new Error("WRN-AI-NO-PROVIDERS"); const duplicate = options.providers.find( (provider, index) => options.providers.findIndex((other) => other.name === provider.name) !== index, ); if (duplicate) throw new Error(`WRN-AI-DUPLICATE-PROVIDER:${duplicate.name}`); const circuits = new Map(); const select = (name?: string) => name ? options.providers.filter((provider) => provider.name === name) : options.providers; const attempts = Math.max(1, options.retry?.attempts ?? 1); const failureThreshold = Math.max(1, options.circuitBreaker?.failureThreshold ?? 5); const resetAfterMs = Math.max(0, options.circuitBreaker?.resetAfterMs ?? 30_000); const generate: AIClient["generate"] = async (prompt, callOptions = {}) => { const providers = select(callOptions.provider); if (!providers.length) throw new AIError(`Unknown AI provider: ${callOptions.provider}`, 0, "provider_error"); let last: unknown; for (const provider of providers) { const circuit = circuits.get(provider.name) ?? { failures: 0 }; if (circuit.openedAt !== undefined && Date.now() - circuit.openedAt < resetAfterMs) { await options.onAttempt?.({ provider: provider.name, attempt: 0, outcome: "circuit-open" }); last = new AIError(`Circuit is open for provider: ${provider.name}`, 0, "circuit_open"); if (callOptions.provider) throw last; continue; } if (circuit.openedAt !== undefined) { circuit.failures = 0; circuit.openedAt = undefined; } for (let attempt = 1; attempt <= attempts; attempt++) { if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? abortError(); const started = Date.now(); await options.onAttempt?.({ provider: provider.name, attempt, outcome: "start" }); try { const result = await provider.generate(prompt, callOptions); circuits.set(provider.name, { failures: 0 }); await options.onAttempt?.({ provider: provider.name, attempt, outcome: "success", durationMs: Date.now() - started, usage: result.usage, }); return result; } catch (error) { last = error; circuit.failures++; if (circuit.failures >= failureThreshold) circuit.openedAt = Date.now(); circuits.set(provider.name, circuit); await options.onAttempt?.({ provider: provider.name, attempt, outcome: "error", durationMs: Date.now() - started, error: safeError(error), }); const retryable = (options.retry?.retry ?? defaultRetry)(error); if (!retryable || attempt === attempts || callOptions.signal?.aborted) break; const backoff = Math.min( options.retry?.maxDelayMs ?? 5_000, (options.retry?.baseDelayMs ?? 100) * 2 ** (attempt - 1), ); await delay(backoff, callOptions.signal); } } if (options.fallback === false || callOptions.provider) throw last; } throw last; }; return { generate, async generateObject( prompt: string | Message[], callOptions: GenerateOptions & { provider?: string; validate?: (value: unknown) => value is T; } = {}, ) { const candidates = select(callOptions.provider); if ( candidates.length && candidates.every((provider) => provider.capabilities?.structuredOutput === false) ) throw new AIError( "Selected provider does not support structured output", 0, "capability_error", ); const result = await generate(prompt, callOptions); let value: unknown; try { value = JSON.parse(jsonText(result.value)); } catch { throw new AIError("AI response was not valid JSON", 0, "structured_output_error"); } if (callOptions.validate && !callOptions.validate(value)) throw new AIError( "AI response failed structured output validation", 0, "structured_output_error", ); return { ...result, value: value as T }; }, async executeTools(result, tools, toolOptions = {}) { const registry = new Map(tools.map((tool) => [tool.name, tool])); const output: Array<{ call: AIToolCall; value: unknown }> = []; for (const call of result.toolCalls ?? []) { if (toolOptions.signal?.aborted) throw toolOptions.signal.reason ?? abortError(); const tool = registry.get(call.name); if (!tool) throw new AIError(`Unknown AI tool: ${call.name}`, 0, "tool_error"); if (tool.validate && !tool.validate(call.arguments)) throw new AIError(`Invalid arguments for AI tool: ${call.name}`, 0, "tool_error"); output.push({ call, value: await tool.execute(call.arguments, { signal: toolOptions.signal }), }); } return output; }, async *stream(prompt, callOptions = {}) { const providers = select(callOptions.provider); let last: unknown; for (const provider of providers) { if (!provider.stream || provider.capabilities?.streaming === false) continue; try { yield* provider.stream(prompt, callOptions); return; } catch (error) { last = error; if (options.fallback === false || callOptions.provider) throw error; } } if (last) throw last; const result = await generate(prompt, callOptions); yield result.value; }, capabilities(provider) { return Object.fromEntries( select(provider).map((item) => [item.name, { ...item.capabilities }]), ); }, }; }