* fix: handle missing @anthropic-ai/claude-code SDK gracefully Add defensive checks to prevent "Right-hand side of 'instanceof' is not an object" errors when the optional Claude Code SDK is not installed. Changes: - Check if AbortError exists before using instanceof - Check if query function exists before calling it - Provide clear error messages when SDK is missing This fixes the issue reported by users in v0.24.0 and v0.25.0 where Task Master would crash with instanceof errors when using the claude-code provider without the SDK installed. * chore: bump @anthropic-ai/claude-code to ^1.0.88 and regenerate lockfile
541 lines
15 KiB
JavaScript
541 lines
15 KiB
JavaScript
/**
|
|
* @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<Object>}
|
|
*/
|
|
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 {
|
|
if (!query) {
|
|
throw new Error(
|
|
"Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider."
|
|
);
|
|
}
|
|
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 (AbortError && 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<Object>}
|
|
*/
|
|
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 {
|
|
if (!query) {
|
|
throw new Error(
|
|
"Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider."
|
|
);
|
|
}
|
|
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 (AbortError && 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
|
|
}
|
|
};
|
|
}
|
|
}
|