/** * @fileoverview Claude Code Language Model implementation */ import { NoSuchModelError } from '@ai-sdk/provider'; import { generateId } from '@ai-sdk/provider-utils'; import { convertToClaudeCodeMessages } from './message-converter.js'; import { extractJson } from './json-extractor.js'; import { createAPICallError, createAuthenticationError } from './errors.js'; let query; let AbortError; async function loadClaudeCodeModule() { if (!query || !AbortError) { try { const mod = await import('@anthropic-ai/claude-code'); query = mod.query; AbortError = mod.AbortError; } catch (err) { throw new Error( "Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider." ); } } } /** * @typedef {import('./types.js').ClaudeCodeSettings} ClaudeCodeSettings * @typedef {import('./types.js').ClaudeCodeModelId} ClaudeCodeModelId * @typedef {import('./types.js').ClaudeCodeLanguageModelOptions} ClaudeCodeLanguageModelOptions */ const modelMap = { opus: 'opus', sonnet: 'sonnet' }; export class ClaudeCodeLanguageModel { specificationVersion = 'v1'; defaultObjectGenerationMode = 'json'; supportsImageUrls = false; supportsStructuredOutputs = false; /** @type {ClaudeCodeModelId} */ modelId; /** @type {ClaudeCodeSettings} */ settings; /** @type {string|undefined} */ sessionId; /** * @param {ClaudeCodeLanguageModelOptions} options */ constructor(options) { this.modelId = options.id; this.settings = options.settings ?? {}; // Validate model ID format if ( !this.modelId || typeof this.modelId !== 'string' || this.modelId.trim() === '' ) { throw new NoSuchModelError({ modelId: this.modelId, modelType: 'languageModel' }); } } get provider() { return 'claude-code'; } /** * Get the model name for Claude Code CLI * @returns {string} */ getModel() { const mapped = modelMap[this.modelId]; return mapped ?? this.modelId; } /** * Generate unsupported parameter warnings * @param {Object} options - Generation options * @returns {Array} Warnings array */ generateUnsupportedWarnings(options) { const warnings = []; const unsupportedParams = []; // Check for unsupported parameters if (options.temperature !== undefined) unsupportedParams.push('temperature'); if (options.maxTokens !== undefined) unsupportedParams.push('maxTokens'); if (options.topP !== undefined) unsupportedParams.push('topP'); if (options.topK !== undefined) unsupportedParams.push('topK'); if (options.presencePenalty !== undefined) unsupportedParams.push('presencePenalty'); if (options.frequencyPenalty !== undefined) unsupportedParams.push('frequencyPenalty'); if (options.stopSequences !== undefined && options.stopSequences.length > 0) unsupportedParams.push('stopSequences'); if (options.seed !== undefined) unsupportedParams.push('seed'); if (unsupportedParams.length > 0) { // Add a warning for each unsupported parameter for (const param of unsupportedParams) { warnings.push({ type: 'unsupported-setting', setting: param, details: `Claude Code CLI does not support the ${param} parameter. It will be ignored.` }); } } return warnings; } /** * Generate text using Claude Code * @param {Object} options - Generation options * @returns {Promise} */ async doGenerate(options) { await loadClaudeCodeModule(); const { messagesPrompt } = convertToClaudeCodeMessages( options.prompt, options.mode ); const abortController = new AbortController(); if (options.abortSignal) { options.abortSignal.addEventListener('abort', () => abortController.abort() ); } const queryOptions = { model: this.getModel(), abortController, resume: this.sessionId, pathToClaudeCodeExecutable: this.settings.pathToClaudeCodeExecutable, customSystemPrompt: this.settings.customSystemPrompt, appendSystemPrompt: this.settings.appendSystemPrompt, maxTurns: this.settings.maxTurns, maxThinkingTokens: this.settings.maxThinkingTokens, cwd: this.settings.cwd, executable: this.settings.executable, executableArgs: this.settings.executableArgs, permissionMode: this.settings.permissionMode, permissionPromptToolName: this.settings.permissionPromptToolName, continue: this.settings.continue, allowedTools: this.settings.allowedTools, disallowedTools: this.settings.disallowedTools, mcpServers: this.settings.mcpServers }; let text = ''; let usage = { promptTokens: 0, completionTokens: 0 }; let finishReason = 'stop'; let costUsd; let durationMs; let rawUsage; const warnings = this.generateUnsupportedWarnings(options); try { const response = query({ prompt: messagesPrompt, options: queryOptions }); for await (const message of response) { if (message.type === 'assistant') { text += message.message.content .map((c) => (c.type === 'text' ? c.text : '')) .join(''); } else if (message.type === 'result') { this.sessionId = message.session_id; costUsd = message.total_cost_usd; durationMs = message.duration_ms; if ('usage' in message) { rawUsage = message.usage; usage = { promptTokens: (message.usage.cache_creation_input_tokens ?? 0) + (message.usage.cache_read_input_tokens ?? 0) + (message.usage.input_tokens ?? 0), completionTokens: message.usage.output_tokens ?? 0 }; } if (message.subtype === 'error_max_turns') { finishReason = 'length'; } else if (message.subtype === 'error_during_execution') { finishReason = 'error'; } } else if (message.type === 'system' && message.subtype === 'init') { this.sessionId = message.session_id; } } } catch (error) { // ------------------------------------------------------------- // Work-around for Claude-Code CLI/SDK JSON truncation bug (#913) // ------------------------------------------------------------- // If the SDK throws a JSON SyntaxError *but* we already hold some // buffered text, assume the response was truncated by the CLI. // We keep the accumulated text, mark the finish reason, push a // provider-warning and *skip* the normal error handling so Task // Master can continue processing. const isJsonTruncation = error instanceof SyntaxError && /JSON/i.test(error.message || '') && (error.message.includes('position') || error.message.includes('Unexpected end')); if (isJsonTruncation && text && text.length > 0) { warnings.push({ type: 'provider-warning', details: 'Claude Code SDK emitted a JSON parse error but Task Master recovered buffered text (possible CLI truncation).' }); finishReason = 'truncated'; // Skip re-throwing: fall through so the caller receives usable data } else { if (error instanceof AbortError) { throw options.abortSignal?.aborted ? options.abortSignal.reason : error; } // Check for authentication errors if ( error.message?.includes('not logged in') || error.message?.includes('authentication') || error.exitCode === 401 ) { throw createAuthenticationError({ message: error.message || 'Authentication failed. Please ensure Claude Code CLI is properly authenticated.' }); } // Wrap other errors with API call error throw createAPICallError({ message: error.message || 'Claude Code CLI error', code: error.code, exitCode: error.exitCode, stderr: error.stderr, promptExcerpt: messagesPrompt.substring(0, 200), isRetryable: error.code === 'ENOENT' || error.code === 'ECONNREFUSED' }); } } // Extract JSON if in object-json mode if (options.mode?.type === 'object-json' && text) { text = extractJson(text); } return { text: text || undefined, usage, finishReason, rawCall: { rawPrompt: messagesPrompt, rawSettings: queryOptions }, warnings: warnings.length > 0 ? warnings : undefined, response: { id: generateId(), timestamp: new Date(), modelId: this.modelId }, request: { body: messagesPrompt }, providerMetadata: { 'claude-code': { ...(this.sessionId !== undefined && { sessionId: this.sessionId }), ...(costUsd !== undefined && { costUsd }), ...(durationMs !== undefined && { durationMs }), ...(rawUsage !== undefined && { rawUsage }) } } }; } /** * Stream text using Claude Code * @param {Object} options - Stream options * @returns {Promise} */ async doStream(options) { await loadClaudeCodeModule(); const { messagesPrompt } = convertToClaudeCodeMessages( options.prompt, options.mode ); const abortController = new AbortController(); if (options.abortSignal) { options.abortSignal.addEventListener('abort', () => abortController.abort() ); } const queryOptions = { model: this.getModel(), abortController, resume: this.sessionId, pathToClaudeCodeExecutable: this.settings.pathToClaudeCodeExecutable, customSystemPrompt: this.settings.customSystemPrompt, appendSystemPrompt: this.settings.appendSystemPrompt, maxTurns: this.settings.maxTurns, maxThinkingTokens: this.settings.maxThinkingTokens, cwd: this.settings.cwd, executable: this.settings.executable, executableArgs: this.settings.executableArgs, permissionMode: this.settings.permissionMode, permissionPromptToolName: this.settings.permissionPromptToolName, continue: this.settings.continue, allowedTools: this.settings.allowedTools, disallowedTools: this.settings.disallowedTools, mcpServers: this.settings.mcpServers }; const warnings = this.generateUnsupportedWarnings(options); const stream = new ReadableStream({ start: async (controller) => { try { const response = query({ prompt: messagesPrompt, options: queryOptions }); let usage = { promptTokens: 0, completionTokens: 0 }; let accumulatedText = ''; for await (const message of response) { if (message.type === 'assistant') { const text = message.message.content .map((c) => (c.type === 'text' ? c.text : '')) .join(''); if (text) { accumulatedText += text; // In object-json mode, we need to accumulate the full text // and extract JSON at the end, so don't stream individual deltas if (options.mode?.type !== 'object-json') { controller.enqueue({ type: 'text-delta', textDelta: text }); } } } else if (message.type === 'result') { let rawUsage; if ('usage' in message) { rawUsage = message.usage; usage = { promptTokens: (message.usage.cache_creation_input_tokens ?? 0) + (message.usage.cache_read_input_tokens ?? 0) + (message.usage.input_tokens ?? 0), completionTokens: message.usage.output_tokens ?? 0 }; } let finishReason = 'stop'; if (message.subtype === 'error_max_turns') { finishReason = 'length'; } else if (message.subtype === 'error_during_execution') { finishReason = 'error'; } // Store session ID in the model instance this.sessionId = message.session_id; // In object-json mode, extract JSON and send the full text at once if (options.mode?.type === 'object-json' && accumulatedText) { const extractedJson = extractJson(accumulatedText); controller.enqueue({ type: 'text-delta', textDelta: extractedJson }); } controller.enqueue({ type: 'finish', finishReason, usage, providerMetadata: { 'claude-code': { sessionId: message.session_id, ...(message.total_cost_usd !== undefined && { costUsd: message.total_cost_usd }), ...(message.duration_ms !== undefined && { durationMs: message.duration_ms }), ...(rawUsage !== undefined && { rawUsage }) } } }); } else if ( message.type === 'system' && message.subtype === 'init' ) { // Store session ID for future use this.sessionId = message.session_id; // Emit response metadata when session is initialized controller.enqueue({ type: 'response-metadata', id: message.session_id, timestamp: new Date(), modelId: this.modelId }); } } // ------------------------------------------------------------- // Work-around for Claude-Code CLI/SDK JSON truncation bug (#913) // ------------------------------------------------------------- // If we hit the SDK JSON SyntaxError but have buffered text, finalize // the stream gracefully instead of emitting an error. const isJsonTruncation = error instanceof SyntaxError && /JSON/i.test(error.message || '') && (error.message.includes('position') || error.message.includes('Unexpected end')); if ( isJsonTruncation && accumulatedText && accumulatedText.length > 0 ) { // Prepare final text payload const finalText = options.mode?.type === 'object-json' ? extractJson(accumulatedText) : accumulatedText; // Emit any remaining text controller.enqueue({ type: 'text-delta', textDelta: finalText }); // Emit finish with truncated reason and warning controller.enqueue({ type: 'finish', finishReason: 'truncated', usage, providerMetadata: { 'claude-code': { truncated: true } }, warnings: [ { type: 'provider-warning', details: 'Claude Code SDK JSON truncation detected; stream recovered.' } ] }); controller.close(); return; // Skip normal error path } controller.close(); } catch (error) { let errorToEmit; if (error instanceof AbortError) { errorToEmit = options.abortSignal?.aborted ? options.abortSignal.reason : error; } else if ( error.message?.includes('not logged in') || error.message?.includes('authentication') || error.exitCode === 401 ) { errorToEmit = createAuthenticationError({ message: error.message || 'Authentication failed. Please ensure Claude Code CLI is properly authenticated.' }); } else { errorToEmit = createAPICallError({ message: error.message || 'Claude Code CLI error', code: error.code, exitCode: error.exitCode, stderr: error.stderr, promptExcerpt: messagesPrompt.substring(0, 200), isRetryable: error.code === 'ENOENT' || error.code === 'ECONNREFUSED' }); } // Emit error as a stream part controller.enqueue({ type: 'error', error: errorToEmit }); controller.close(); } } }); return { stream, rawCall: { rawPrompt: messagesPrompt, rawSettings: queryOptions }, warnings: warnings.length > 0 ? warnings : undefined, request: { body: messagesPrompt } }; } }