/** * Claude Code Provider * * Wraps `@anthropic-ai/claude-agent-sdk` so consumers can use a local * Claude Code installation — including Claude Pro/Max subscription * accounts authenticated via `claude login` — through the same * BaseAIProvider interface as the other providers. * * Tradeoffs vs the direct Anthropic API provider: * - Authenticates via the local CLI, so subscription users (no API key) * can use it. * - Requires `claude` to be installed and logged in on the host. * - Higher latency (shells out to a CLI process per request). * - Designed for agent workflows, but used here in single-turn mode * (maxTurns: 1, no tools) for plain text completion. * * @see https://docs.anthropic.com/claude/docs/claude-code-sdk */ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { AIMessage, AIProviderConfig, CompletionChunk, CompletionParams, CompletionResponse, ProviderInfo } from '../types/index.js'; import { BaseAIProvider } from './base.js'; import { AIProviderError, AIErrorType } from '../types/index.js'; import { DEFAULT_MODELS } from '../constants.js'; /** * Configuration for the Claude Code provider. `apiKey` is optional — * when omitted the SDK falls back to ANTHROPIC_API_KEY in the * environment or the local subscription credentials managed by the * `claude` CLI. */ export interface ClaudeCodeConfig extends Omit { apiKey?: string; /** * Default model. Accepts SDK aliases (`'sonnet'`, `'opus'`, `'haiku'`, * `'inherit'`) or any full Claude model ID. * @default 'sonnet' */ defaultModel?: string; /** * Working directory for the Claude Code session. * @default process.cwd() */ cwd?: string; /** * Tool names the model is allowed to call. Defaults to none, which * makes the provider behave as a pure text completion endpoint. */ allowedTools?: string[]; /** * Maximum agent turns. Defaults to 1 for plain completion; raise this * if you also pass `allowedTools` and want tool-use loops. * @default 1 */ maxTurns?: number; } export class ClaudeCodeProvider extends BaseAIProvider { private readonly defaultModel: string; private readonly cwd: string | undefined; private readonly allowedTools: string[]; private readonly maxTurns: number; constructor(config: ClaudeCodeConfig) { super(config as AIProviderConfig); this.defaultModel = config.defaultModel || DEFAULT_MODELS['claude-code']; this.cwd = config.cwd; this.allowedTools = config.allowedTools ?? []; this.maxTurns = config.maxTurns ?? 1; } /** * apiKey is optional for this provider — the SDK reads * ANTHROPIC_API_KEY from the environment or falls back to the local * Claude Code credentials. We override the base validator to skip the * non-empty apiKey requirement. */ protected override validateAndNormalizeConfig(config: AIProviderConfig): AIProviderConfig { if (!config) { throw new AIProviderError( 'Configuration object is required', AIErrorType.INVALID_REQUEST ); } if (config.apiKey !== undefined && (typeof config.apiKey !== 'string')) { throw new AIProviderError( 'API key, when provided, must be a string', AIErrorType.INVALID_REQUEST ); } return { ...config, apiKey: config.apiKey ?? '', timeout: config.timeout, maxRetries: config.maxRetries }; } protected async doInitialize(): Promise { // The SDK lazy-spawns the CLI per query; no connection probe is // necessary (and a probe would cost a real billed turn). We defer // auth/model errors to the first complete()/stream() call. if (this.config.apiKey) { process.env.ANTHROPIC_API_KEY = this.config.apiKey; } } protected async doComplete(params: CompletionParams): Promise> { const { systemPrompt, prompt } = this.buildPrompt(params.messages); const model = params.model || this.defaultModel; let collectedText = ''; let sessionId = ''; let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; for await (const message of query({ prompt, options: this.buildOptions(model, systemPrompt) })) { if (message.type === 'system' && message.subtype === 'init') { sessionId = message.session_id; continue; } if (message.type === 'assistant') { if (message.error) { throw this.errorForSdkAssistantError(message.error); } collectedText += extractText(message.message.content); continue; } if (message.type === 'result') { usage = { promptTokens: message.usage?.input_tokens ?? 0, completionTokens: message.usage?.output_tokens ?? 0, totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0) }; if (message.subtype !== 'success') { throw new AIProviderError( `Claude Code session ended without a successful result: ${message.subtype}`, AIErrorType.UNKNOWN ); } return { content: collectedText || message.result, model, usage, id: sessionId || message.uuid, metadata: { costUsd: message.total_cost_usd, durationMs: message.duration_ms, numTurns: message.num_turns, stopReason: message.stop_reason } }; } } throw new AIProviderError( 'Claude Code stream ended without a result message', AIErrorType.UNKNOWN ); } protected async *doStream( params: CompletionParams ): AsyncIterable { const { systemPrompt, prompt } = this.buildPrompt(params.messages); const model = params.model || this.defaultModel; let sessionId = ''; let yieldedText = ''; for await (const message of query({ prompt, options: this.buildOptions(model, systemPrompt) })) { if (message.type === 'system' && message.subtype === 'init') { sessionId = message.session_id; continue; } if (message.type === 'assistant') { if (message.error) { throw this.errorForSdkAssistantError(message.error); } const fullText = extractText(message.message.content); const delta = fullText.slice(yieldedText.length); if (delta) { yield { content: delta, isComplete: false, id: sessionId }; yieldedText = fullText; } continue; } if (message.type === 'result') { const usage = { promptTokens: message.usage?.input_tokens ?? 0, completionTokens: message.usage?.output_tokens ?? 0, totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0) }; if (message.subtype !== 'success') { throw new AIProviderError( `Claude Code session ended without a successful result: ${message.subtype}`, AIErrorType.UNKNOWN ); } yield { content: '', isComplete: true, id: sessionId, usage }; return; } } } public getInfo(): ProviderInfo { return { name: 'Claude Code', version: '1.0.0', models: ['sonnet', 'opus', 'haiku', 'inherit'], maxContextLength: 200000, supportsStreaming: true, capabilities: { vision: true, functionCalling: true, systemMessages: true, reasoning: true, codeGeneration: true, subscriptionAuth: true, requiresLocalCli: true } }; } protected override mapProviderError(error: any): AIProviderError | null { if (!error) return null; const message: string = error.message || ''; if (message.includes('ENOENT') && message.toLowerCase().includes('claude')) { return new AIProviderError( 'Claude Code CLI not found. Install it from https://docs.anthropic.com/claude/docs/claude-code and run `claude login` for subscription accounts.', AIErrorType.INVALID_REQUEST, undefined, error ); } if (message.includes('not logged in') || message.includes('authentication')) { return new AIProviderError( 'Claude Code is not authenticated. Run `claude login` or set ANTHROPIC_API_KEY in the environment.', AIErrorType.AUTHENTICATION, undefined, error ); } return null; } private buildPrompt(messages: AIMessage[]): { systemPrompt: string | undefined; prompt: string } { let systemPrompt: string | undefined; const turns: AIMessage[] = []; for (const msg of messages) { if (msg.role === 'system') { systemPrompt = systemPrompt ? `${systemPrompt}\n\n${msg.content}` : msg.content; } else { turns.push(msg); } } if (turns.length === 0) { throw new AIProviderError( 'At least one user or assistant message is required', AIErrorType.INVALID_REQUEST ); } // Single user turn: pass the content directly. Multi-turn history is // flattened with role labels — the Agent SDK doesn't accept a // messages array in stateless mode, so this is the simplest faithful // representation of the conversation. if (turns.length === 1 && turns[0]!.role === 'user') { return { systemPrompt, prompt: turns[0]!.content }; } const flattened = turns .map(t => `${t.role === 'assistant' ? 'Assistant' : 'Human'}: ${t.content}`) .join('\n\n'); return { systemPrompt, prompt: `${flattened}\n\nAssistant:` }; } private buildOptions(model: string, systemPrompt: string | undefined) { const options: Record = { model, maxTurns: this.maxTurns, allowedTools: this.allowedTools }; if (systemPrompt) options.systemPrompt = systemPrompt; if (this.cwd) options.cwd = this.cwd; return options as any; } private errorForSdkAssistantError(code: string): AIProviderError { const map: Record = { authentication_failed: { type: AIErrorType.AUTHENTICATION, message: 'Claude Code authentication failed. Run `claude login` or set ANTHROPIC_API_KEY.' }, oauth_org_not_allowed: { type: AIErrorType.AUTHENTICATION, message: 'Your organization is not allowed by the Claude Code OAuth policy.' }, billing_error: { type: AIErrorType.AUTHENTICATION, message: 'Billing error. Check your Anthropic account billing or subscription status.' }, rate_limit: { type: AIErrorType.RATE_LIMIT, message: 'Rate limit exceeded.' }, invalid_request: { type: AIErrorType.INVALID_REQUEST, message: 'Invalid request to Claude Code.' }, model_not_found: { type: AIErrorType.MODEL_NOT_FOUND, message: 'Requested model is not available to this Claude Code installation.' }, server_error: { type: AIErrorType.NETWORK, message: 'Claude Code server error. Try again shortly.' }, max_output_tokens: { type: AIErrorType.INVALID_REQUEST, message: 'Response exceeded the configured maxOutputTokens.' } }; const entry = map[code] ?? { type: AIErrorType.UNKNOWN, message: `Claude Code error: ${code}` }; return new AIProviderError(entry.message, entry.type); } } function extractText(content: unknown): string { if (!Array.isArray(content)) return ''; let out = ''; for (const block of content) { if (block && typeof block === 'object' && (block as any).type === 'text') { out += (block as any).text ?? ''; } } return out; }