@llumiverse/drivers
Version:
LLM driver implementations. Currently supported are: openai, huggingface, bedrock, replicate.
880 lines (785 loc) • 37.4 kB
text/typescript
/**
* Shared utilities for Anthropic SDK-based drivers.
*
* Used by both the native AnthropicDriver (drivers/src/anthropic/) and the
* VertexAI Claude pathway (drivers/src/vertexai/models/claude.ts). Both use
* the same Anthropic Messages API surface — the only difference is the client
* (Anthropic vs AnthropicVertex) and how auth is wired up.
*/
import type Anthropic from '@anthropic-ai/sdk';
import {
AnthropicError,
APIConnectionError,
APIConnectionTimeoutError,
APIError,
APIUserAbortError,
AuthenticationError,
BadRequestError,
ConflictError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
UnprocessableEntityError,
} from '@anthropic-ai/sdk/error';
import type {
ContentBlock,
ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
Message,
MessageParam,
TextBlockParam,
ToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/index.js';
import type { MessageStreamParams } from '@anthropic-ai/sdk/resources/index.mjs';
import type {
MessageCreateParamsBase,
RawMessageStreamEvent,
} from '@anthropic-ai/sdk/resources/messages.js';
import type AnthropicVertex from '@anthropic-ai/vertex-sdk';
import { getClaudeMaxTokensLimit } from '@llumiverse/common';
import {
type Completion,
type CompletionChunkObject,
type CompletionResult,
type ExecutionOptions,
type ExecutionTokenUsage,
getConversationMeta,
incrementConversationTurn,
type JSONObject,
LlumiverseError,
type LlumiverseErrorContext,
PromptRole,
type PromptSegment,
readStreamAsBase64,
readStreamAsString,
type StatelessExecutionOptions,
stripBase64ImagesFromConversation,
stripHeartbeatsFromConversation,
type ToolUse,
truncateLargeTextInConversation,
} from '@llumiverse/core';
import { asyncMap } from '@llumiverse/core/async';
import { resolveClaudeThinking } from './claude-thinking.js';
// ============================================================================
// Types
// ============================================================================
export interface ClaudePrompt {
messages: MessageParam[];
system?: TextBlockParam[];
}
export interface AnthropicUsageLike {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
}
/**
* Duck-typed options interface accepted by the shared Claude utilities.
* Both `AnthropicClaudeOptions` and `VertexAIClaudeOptions` satisfy this structurally.
*/
export interface ClaudeBaseOptions {
_option_id?: string;
max_tokens?: number;
temperature?: number;
top_p?: number;
top_k?: number;
stop_sequence?: string[];
effort?: string;
thinking_budget_tokens?: number;
include_thoughts?: boolean;
cache_enabled?: boolean;
cache_ttl?: string;
}
interface RequestOptions {
headers?: Record<string, string>;
}
type ClaudeTool = NonNullable<MessageCreateParamsBase['tools']>[number];
// ============================================================================
// Token usage
// ============================================================================
export function anthropicUsageToTokenUsage(usage: AnthropicUsageLike): ExecutionTokenUsage {
const cacheRead = usage.cache_read_input_tokens ?? 0;
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
return {
prompt_new: usage.input_tokens,
prompt: usage.input_tokens + cacheRead + cacheWrite,
result: usage.output_tokens,
total: usage.input_tokens + usage.output_tokens + cacheRead + cacheWrite,
prompt_cached: usage.cache_read_input_tokens ?? undefined,
prompt_cache_write: usage.cache_creation_input_tokens ?? undefined,
};
}
// ============================================================================
// Finish reason
// ============================================================================
export function claudeFinishReason(reason: string | undefined): string | undefined {
if (!reason) return undefined;
switch (reason) {
case 'end_turn': return 'stop';
case 'max_tokens': return 'length';
default: return reason; // stop_sequence, tool_use
}
}
// ============================================================================
// Content extraction
// ============================================================================
export function collectClaudeTools(content: ContentBlock[]): ToolUse[] | undefined {
const out: ToolUse[] = [];
for (const block of content) {
if (block.type === 'tool_use') {
out.push({
id: block.id,
tool_name: block.name,
tool_input: block.input as JSONObject,
});
}
}
return out.length > 0 ? out : undefined;
}
export function collectAllTextContent(content: ContentBlock[], includeThoughts = false): string {
const textParts: string[] = [];
if (includeThoughts) {
for (const block of content) {
if (block.type === 'thinking' && block.thinking) {
textParts.push(block.thinking);
} else if (block.type === 'redacted_thinking' && block.data) {
textParts.push(`[Redacted thinking: ${block.data}]`);
}
}
if (textParts.length > 0) {
textParts.push('');
}
}
for (const block of content) {
if (block.type === 'text' && block.text) {
textParts.push(block.text);
}
}
return textParts.join('\n');
}
// ============================================================================
// Max tokens
// ============================================================================
export function claudeMaxTokens(option: StatelessExecutionOptions): number {
const modelOptions = option.model_options as ClaudeBaseOptions | undefined;
if (modelOptions && typeof modelOptions.max_tokens === 'number') {
return modelOptions.max_tokens;
}
let maxSupportedTokens = getClaudeMaxTokensLimit(option.model);
// Claude 3.7 supports up to 128k with a beta header; default to 64k when no budget is set.
if (option.model.includes('claude-3-7-sonnet') && (modelOptions?.thinking_budget_tokens ?? 0) < 48000) {
maxSupportedTokens = 64000;
}
return maxSupportedTokens;
}
// ============================================================================
// File / multimodal block helpers
// ============================================================================
async function collectFileBlocks(segment: PromptSegment, restrictedTypes: true): Promise<Array<TextBlockParam | ImageBlockParam>>;
async function collectFileBlocks(segment: PromptSegment, restrictedTypes?: false): Promise<ContentBlockParam[]>;
async function collectFileBlocks(segment: PromptSegment, restrictedTypes = false): Promise<ContentBlockParam[]> {
const contentBlocks: ContentBlockParam[] = [];
for (const file of segment.files || []) {
if (file.mime_type?.startsWith('image/')) {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.mime_type)) {
throw new Error(`Unsupported image type: ${file.mime_type}`);
}
const mimeType = String(file.mime_type) as 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
data: await readStreamAsBase64(await file.getStream()),
media_type: mimeType,
},
} satisfies ImageBlockParam);
} else if (!restrictedTypes) {
if (file.mime_type === 'application/pdf') {
contentBlocks.push({
title: file.name,
type: 'document',
source: {
type: 'base64',
data: await readStreamAsBase64(await file.getStream()),
media_type: 'application/pdf',
},
} satisfies DocumentBlockParam);
} else if (file.mime_type?.startsWith('text/')) {
contentBlocks.push({
title: file.name,
type: 'document',
source: {
type: 'text',
data: await readStreamAsString(await file.getStream()),
media_type: 'text/plain',
},
} satisfies DocumentBlockParam);
}
}
}
return contentBlocks;
}
// ============================================================================
// Prompt formatting (PromptSegment[] → ClaudePrompt)
// ============================================================================
export async function formatClaudePrompt(segments: PromptSegment[], options: ExecutionOptions): Promise<ClaudePrompt> {
let system: TextBlockParam[] | undefined = segments
.filter((s) => s.role === PromptRole.system)
.map((s) => ({ text: s.content, type: 'text' as const }));
if (options.result_schema) {
const schemaText = options.tools && options.tools.length > 0
? 'When not calling tools, the answer must be a JSON object using the following JSON Schema:\n' + JSON.stringify(options.result_schema)
: 'The answer must be a JSON object using the following JSON Schema:\n' + JSON.stringify(options.result_schema);
system.push({ text: schemaText, type: 'text' as const });
}
let messages: MessageParam[] = [];
const safetyMessages: MessageParam[] = [];
for (const segment of segments) {
if (segment.role === PromptRole.system) continue;
if (segment.role === PromptRole.tool) {
if (!segment.tool_use_id) {
throw new Error('Tool prompt segment must have a tool use ID');
}
const contentBlocks: Array<TextBlockParam | ImageBlockParam> = [];
if (segment.content) {
contentBlocks.push({ type: 'text', text: segment.content } satisfies TextBlockParam);
}
contentBlocks.push(...(await collectFileBlocks(segment, true)));
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: segment.tool_use_id,
content: contentBlocks,
} satisfies ToolResultBlockParam],
});
} else {
const contentBlocks: ContentBlockParam[] = [];
if (segment.content) {
contentBlocks.push({ type: 'text', text: segment.content } satisfies TextBlockParam);
}
contentBlocks.push(...(await collectFileBlocks(segment, false)));
if (contentBlocks.length === 0) continue;
const messageParam: MessageParam = {
role: segment.role === PromptRole.assistant ? 'assistant' : 'user',
content: contentBlocks,
};
if (segment.role === PromptRole.safety) {
safetyMessages.push(messageParam);
} else {
messages.push(messageParam);
}
}
}
messages = messages.concat(safetyMessages);
if (system && system.length === 0) system = undefined;
return { messages, system };
}
// ============================================================================
// Conversation management
// ============================================================================
export function createPromptFromResponse(response: Message): ClaudePrompt {
return {
messages: [{ role: response.role, content: response.content }],
system: undefined,
};
}
export function mergeConsecutiveUserMessages(messages: MessageParam[]): MessageParam[] {
if (messages.length === 0) return [];
const needsMerging = messages.some((msg, i) =>
i < messages.length - 1 && msg.role === 'user' && messages[i + 1].role === 'user'
);
if (!needsMerging) return messages;
const result: MessageParam[] = [];
let i = 0;
while (i < messages.length) {
const current = messages[i];
if (current.role === 'user') {
const mergedContent: MessageParam['content'] = [];
while (i < messages.length && messages[i].role === 'user') {
const userMsg = messages[i];
if (Array.isArray(userMsg.content)) {
mergedContent.push(...userMsg.content);
} else if (typeof userMsg.content === 'string') {
mergedContent.push({ type: 'text', text: userMsg.content });
}
i++;
}
result.push({ role: 'user', content: mergedContent });
} else {
result.push(current);
i++;
}
}
return result;
}
export function sanitizeMessages(messages: MessageParam[]): MessageParam[] {
const result: MessageParam[] = [];
for (const message of messages) {
if (typeof message.content === 'string') {
if (message.content.trim()) result.push(message);
continue;
}
const filteredContent = message.content.filter((block) => {
if (block.type === 'text') return block.text && block.text.trim().length > 0;
return true;
});
if (filteredContent.length > 0) {
result.push({ ...message, content: filteredContent });
}
}
return result;
}
export function fixOrphanedToolUse(messages: MessageParam[]): MessageParam[] {
if (messages.length < 2) return messages;
const result: MessageParam[] = [];
for (let i = 0; i < messages.length; i++) {
const current = messages[i];
result.push(current);
if (current.role === 'assistant' && Array.isArray(current.content)) {
const toolUseBlocks = current.content.filter(
(block): block is ContentBlockParam & { type: 'tool_use'; id: string; name: string } =>
block.type === 'tool_use'
);
if (toolUseBlocks.length > 0) {
const nextMessage = messages[i + 1];
if (nextMessage && nextMessage.role === 'user' && Array.isArray(nextMessage.content)) {
const toolResultIds = new Set(
nextMessage.content
.filter((block): block is ToolResultBlockParam => block.type === 'tool_result')
.map((block) => block.tool_use_id)
);
const orphaned = toolUseBlocks.filter((block) => !toolResultIds.has(block.id));
if (orphaned.length > 0) {
const syntheticResults: ToolResultBlockParam[] = orphaned.map((block) => ({
type: 'tool_result',
tool_use_id: block.id,
content: `[Tool interrupted: The user stopped the operation before "${block.name}" could execute.]`,
}));
messages[i + 1] = { ...nextMessage, content: [...syntheticResults, ...nextMessage.content] };
}
} else if (nextMessage && nextMessage.role === 'user') {
const syntheticResults: ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
type: 'tool_result',
tool_use_id: block.id,
content: `[Tool interrupted: The user stopped the operation before "${block.name}" could execute.]`,
}));
const textContent: TextBlockParam = typeof nextMessage.content === 'string'
? { type: 'text', text: nextMessage.content }
: { type: 'text', text: '' };
messages[i + 1] = { role: 'user', content: [...syntheticResults, textContent] };
}
}
}
}
return result;
}
export function updateClaudeConversation(conversation: ClaudePrompt | undefined | null, prompt: ClaudePrompt): ClaudePrompt {
const baseSystemMessages = conversation?.system || [];
const baseMessages = conversation?.messages || [];
const system = baseSystemMessages.concat(prompt.system || []);
const combined = sanitizeMessages(baseMessages.concat(prompt.messages || []));
const mergedMessages = mergeConsecutiveUserMessages(combined);
return {
messages: mergedMessages,
system: system.length > 0 ? system : undefined,
};
}
export function claudeMessagesContainToolBlocks(messages: MessageParam[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue;
for (const block of msg.content) {
if (typeof block === 'object' && block !== null && 'type' in block) {
if (block.type === 'tool_use' || block.type === 'tool_result') return true;
}
}
}
return false;
}
export function convertClaudeToolBlocksToText(messages: MessageParam[]): MessageParam[] {
return messages.map((msg) => {
if (!Array.isArray(msg.content)) return msg;
let hasToolBlocks = false;
for (const block of msg.content) {
if (typeof block === 'object' && block !== null && 'type' in block &&
(block.type === 'tool_use' || block.type === 'tool_result')) {
hasToolBlocks = true;
break;
}
}
if (!hasToolBlocks) return msg;
const newContent: MessageParam['content'] = [];
for (const block of msg.content) {
if (typeof block === 'string') { newContent.push(block); continue; }
if (block.type === 'tool_use') {
const inputStr = block.input ? JSON.stringify(block.input) : '';
const truncated = inputStr.length > 500 ? inputStr.substring(0, 500) + '...' : inputStr;
(newContent as Array<{ type: 'text'; text: string }>).push({ type: 'text', text: `[Tool call: ${block.name}(${truncated})]` });
} else if (block.type === 'tool_result') {
let resultStr = 'No content';
if (typeof block.content === 'string') {
resultStr = block.content.length > 500 ? block.content.substring(0, 500) + '...' : block.content;
} else if (Array.isArray(block.content)) {
const texts = block.content
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map((c) => (c.text.length > 500 ? c.text.substring(0, 500) + '...' : c.text));
resultStr = texts.join('\n') || 'No text content';
}
(newContent as Array<{ type: 'text'; text: string }>).push({ type: 'text', text: `[Tool result: ${resultStr}]` });
} else {
newContent.push(block as ContentBlockParam);
}
}
return { ...msg, content: newContent };
});
}
// ============================================================================
// Cache control stripping
// ============================================================================
function stripClaudeCacheControlFromBlock<T extends ContentBlockParam>(block: T): T {
if (typeof block === 'object' && block !== null && 'cache_control' in block) {
const { cache_control: _cc, ...rest } = block as T & { cache_control: unknown };
return rest as T;
}
return block;
}
function stripClaudeCacheControlFromMessages(messages: MessageParam[]): MessageParam[] {
return messages.map((msg) => {
if (!Array.isArray(msg.content)) return msg;
return { ...msg, content: msg.content.map(stripClaudeCacheControlFromBlock) };
});
}
function stripClaudeCacheControlFromSystem(system?: TextBlockParam[]): TextBlockParam[] | undefined {
if (!system) return undefined;
return system.map(stripClaudeCacheControlFromBlock);
}
function stripClaudeCacheControlFromTools(
tools?: MessageCreateParamsBase['tools']
): MessageCreateParamsBase['tools'] | undefined {
if (!tools) return undefined;
return tools.map((tool) => {
if ('cache_control' in tool) {
const { cache_control: _cc, ...rest } = tool as ClaudeTool & { cache_control: unknown };
return rest as ClaudeTool;
}
return tool;
});
}
// ============================================================================
// Payload builder
// ============================================================================
export function getClaudePayload(
options: ExecutionOptions,
prompt: ClaudePrompt
): { payload: MessageCreateParamsBase; requestOptions: RequestOptions | undefined } {
const modelName = options.model;
const model_options = options.model_options as ClaudeBaseOptions | undefined;
let requestOptions: RequestOptions | undefined;
if (modelName.includes('claude-3-7-sonnet') &&
((model_options?.max_tokens ?? 0) > 64000 || (model_options?.thinking_budget_tokens ?? 0) > 64000)) {
requestOptions = { headers: { 'anthropic-beta': 'output-128k-2025-02-19' } };
}
const fixedMessages = fixOrphanedToolUse(prompt.messages);
let sanitizedMessages = sanitizeMessages(fixedMessages);
if (options.tools) {
for (const tool of options.tools) {
if (tool.input_schema.type !== 'object') {
throw new Error(`Tool "${tool.name}" has invalid input_schema.type: expected "object", got "${tool.input_schema.type}"`);
}
}
}
const hasTools = options.tools && options.tools.length > 0;
if (!hasTools && claudeMessagesContainToolBlocks(sanitizedMessages)) {
sanitizedMessages = convertClaudeToolBlocksToText(sanitizedMessages);
}
sanitizedMessages = stripClaudeCacheControlFromMessages(sanitizedMessages);
const sanitizedSystem = stripClaudeCacheControlFromSystem(prompt.system);
const sanitizedTools = hasTools
? stripClaudeCacheControlFromTools(options.tools as MessageCreateParamsBase['tools'])
: undefined;
const cacheEnabled = model_options?.cache_enabled === true;
if (cacheEnabled) {
const cacheTtl = model_options?.cache_ttl as '5m' | '1h' | undefined;
const cacheControl = { type: 'ephemeral' as const, ...(cacheTtl && { ttl: cacheTtl }) };
if (sanitizedSystem && sanitizedSystem.length > 0) {
const lastBlock = sanitizedSystem[sanitizedSystem.length - 1] as TextBlockParam & { cache_control?: unknown };
lastBlock.cache_control = cacheControl;
}
if (sanitizedTools && sanitizedTools.length > 0) {
const lastTool = sanitizedTools[sanitizedTools.length - 1] as ClaudeTool & { cache_control?: unknown };
lastTool.cache_control = cacheControl;
}
if (sanitizedMessages.length >= 4) {
const pivotMsg = sanitizedMessages[sanitizedMessages.length - 2];
if (Array.isArray(pivotMsg.content) && pivotMsg.content.length > 0) {
const lastBlock = pivotMsg.content[pivotMsg.content.length - 1];
if (typeof lastBlock === 'object' && lastBlock !== null && 'type' in lastBlock &&
lastBlock.type !== 'thinking' && lastBlock.type !== 'redacted_thinking') {
(lastBlock as TextBlockParam).cache_control = cacheControl;
}
}
}
}
const { thinking, outputConfig, hasSamplingRestriction } = resolveClaudeThinking(modelName, model_options as Parameters<typeof resolveClaudeThinking>[1]);
const payload: MessageCreateParamsBase = {
messages: sanitizedMessages,
system: sanitizedSystem,
tools: sanitizedTools,
temperature: hasSamplingRestriction ? undefined : model_options?.temperature,
model: modelName,
max_tokens: claudeMaxTokens(options),
top_p: hasSamplingRestriction ? undefined : (model_options?.temperature != null ? undefined : model_options?.top_p),
top_k: hasSamplingRestriction ? undefined : model_options?.top_k,
stop_sequences: model_options?.stop_sequence,
thinking,
stream: true,
...(outputConfig && { output_config: outputConfig }),
};
return { payload, requestOptions };
}
// ============================================================================
// Streaming conversation builder (called after stream completes)
// ============================================================================
export function buildClaudeStreamingConversation(
prompt: ClaudePrompt,
result: unknown[],
toolUse: unknown[] | undefined,
options: ExecutionOptions
): ClaudePrompt {
const completionResults = result as CompletionResult[];
const text = completionResults
.filter((r) => r.type === 'text')
.map((r) => r.value as string)
.join('');
let conversation = updateClaudeConversation(options.conversation as ClaudePrompt | undefined, prompt);
if (text) {
const assistantMsg: MessageParam = { role: 'assistant', content: text };
conversation = updateClaudeConversation(conversation, { messages: [assistantMsg] });
}
if (toolUse && toolUse.length > 0) {
const toolBlocks: ContentBlockParam[] = (toolUse as ToolUse[]).map((t) => ({
type: 'tool_use' as const,
id: t.id,
name: t.tool_name,
input: t.tool_input ?? {},
}));
const assistantToolMsg: MessageParam = { role: 'assistant', content: toolBlocks };
conversation = updateClaudeConversation(conversation, { messages: [assistantToolMsg] });
}
conversation = incrementConversationTurn(conversation) as ClaudePrompt;
const currentTurn = getConversationMeta(conversation).turnNumber;
const stripOptions = {
keepForTurns: options.stripImagesAfterTurns ?? Infinity,
currentTurn,
textMaxTokens: options.stripTextMaxTokens,
};
let processed = stripBase64ImagesFromConversation(conversation, stripOptions);
processed = truncateLargeTextInConversation(processed, stripOptions);
processed = stripHeartbeatsFromConversation(processed, {
keepForTurns: options.stripHeartbeatsAfterTurns ?? 1,
currentTurn,
});
return processed as ClaudePrompt;
}
// ============================================================================
// Execution helpers (standalone, take a client parameter)
// ============================================================================
/**
* Execute a non-streaming Claude completion.
* Works with any Anthropic-compatible client (Anthropic or AnthropicVertex).
*/
export async function executeClaudeCompletion(
client: Anthropic | AnthropicVertex,
prompt: ClaudePrompt,
options: ExecutionOptions,
): Promise<Completion> {
const model_options = options.model_options as ClaudeBaseOptions | undefined;
let conversation = updateClaudeConversation(options.conversation as ClaudePrompt | undefined, prompt);
const { payload, requestOptions } = getClaudePayload(options, conversation);
const result: Message = await client.messages.stream(payload, requestOptions).finalMessage();
const includeThoughts = model_options?.include_thoughts ?? false;
const text = collectAllTextContent(result.content, includeThoughts);
const tool_use = collectClaudeTools(result.content);
conversation = updateClaudeConversation(conversation, createPromptFromResponse(result));
conversation = incrementConversationTurn(conversation) as ClaudePrompt;
const currentTurn = getConversationMeta(conversation).turnNumber;
const stripOpts = {
keepForTurns: options.stripImagesAfterTurns ?? Infinity,
currentTurn,
textMaxTokens: options.stripTextMaxTokens,
};
let processedConversation = stripBase64ImagesFromConversation(conversation, stripOpts);
processedConversation = truncateLargeTextInConversation(processedConversation, stripOpts);
processedConversation = stripHeartbeatsFromConversation(processedConversation, {
keepForTurns: options.stripHeartbeatsAfterTurns ?? 1,
currentTurn,
});
return {
result: text ? [{ type: 'text', value: text }] : [{ type: 'text', value: '' }],
tool_use,
token_usage: anthropicUsageToTokenUsage(result.usage),
finish_reason: tool_use ? 'tool_use' : claudeFinishReason(result?.stop_reason ?? ''),
conversation: processedConversation,
};
}
/**
* Execute a streaming Claude completion.
* Works with any Anthropic-compatible client (Anthropic or AnthropicVertex).
*/
export async function streamClaudeCompletion(
client: Anthropic | AnthropicVertex,
prompt: ClaudePrompt,
options: ExecutionOptions,
): Promise<AsyncIterable<CompletionChunkObject>> {
const model_options = options.model_options as ClaudeBaseOptions | undefined;
const conversation = updateClaudeConversation(options.conversation as ClaudePrompt | undefined, prompt);
const { payload, requestOptions } = getClaudePayload(options, conversation);
const streamingPayload: MessageStreamParams = { ...payload, stream: true };
const response_stream = await client.messages.stream(streamingPayload, requestOptions);
let currentToolUse: { id: string; name: string; inputJson: string } | null = null;
let pendingSpacing = false;
const stream = asyncMap(response_stream, async (streamEvent: RawMessageStreamEvent) => {
switch (streamEvent.type) {
case 'message_start':
return {
result: [{ type: 'text', value: '' }],
token_usage: anthropicUsageToTokenUsage(streamEvent.message.usage as AnthropicUsageLike),
} satisfies CompletionChunkObject;
case 'message_delta':
return {
result: [{ type: 'text', value: '' }],
token_usage: { result: streamEvent.usage.output_tokens },
finish_reason: claudeFinishReason(streamEvent.delta.stop_reason ?? undefined),
} satisfies CompletionChunkObject;
case 'content_block_start':
if (streamEvent.content_block.type === 'tool_use') {
currentToolUse = { id: streamEvent.content_block.id, name: streamEvent.content_block.name, inputJson: '' };
return {
result: [],
tool_use: [{
id: streamEvent.content_block.id,
tool_name: streamEvent.content_block.name,
tool_input: '' as unknown as JSONObject,
}],
} satisfies CompletionChunkObject;
}
if (streamEvent.content_block.type === 'redacted_thinking' && model_options?.include_thoughts) {
return {
result: [{ type: 'text', value: `[Redacted thinking: ${streamEvent.content_block.data}]` }],
} satisfies CompletionChunkObject;
}
break;
case 'content_block_delta':
switch (streamEvent.delta.type) {
case 'text_delta': {
const prefix = pendingSpacing ? '\n\n' : '';
pendingSpacing = false;
return {
result: streamEvent.delta.text ? [{ type: 'text', value: prefix + streamEvent.delta.text }] : [],
} satisfies CompletionChunkObject;
}
case 'input_json_delta':
if (currentToolUse && streamEvent.delta.partial_json) {
return {
result: [],
tool_use: [{
id: currentToolUse.id,
tool_name: '',
tool_input: streamEvent.delta.partial_json as unknown as JSONObject,
}],
} satisfies CompletionChunkObject;
}
break;
case 'thinking_delta':
if (model_options?.include_thoughts) {
return {
result: streamEvent.delta.thinking ? [{ type: 'text', value: streamEvent.delta.thinking }] : [],
} satisfies CompletionChunkObject;
}
break;
case 'signature_delta':
if (model_options?.include_thoughts) {
pendingSpacing = true;
}
break;
}
break;
case 'content_block_stop':
if (currentToolUse) {
currentToolUse = null;
pendingSpacing = false;
}
break;
}
return { result: [] } satisfies CompletionChunkObject;
});
return stream;
}
// ============================================================================
// Error handling
// ============================================================================
export function formatAnthropicLlumiverseError(error: unknown, context: LlumiverseErrorContext): LlumiverseError {
if (error instanceof AnthropicError && !(error instanceof APIError)) {
// Client-side SDK error (e.g. "Streaming is required for operations that may take longer than 10 minutes").
// These are structural/configuration errors — retrying will never succeed.
const errorName = error.constructor?.name || 'AnthropicError';
return new LlumiverseError(`[${context.provider}] ${error.message}`, false, context, error, undefined, errorName);
}
if (!(error instanceof APIError)) {
// Not an Anthropic error — rethrow for default handling
throw error;
}
const apiError = error as APIError;
const httpStatusCode = apiError.status;
let message = apiError.message || String(error);
let errorType: string | undefined;
if (apiError.error && typeof apiError.error === 'object') {
const nested = apiError.error as Record<string, unknown>;
if (nested['error'] && typeof nested['error'] === 'object') {
const innerError = nested['error'] as Record<string, unknown>;
errorType = innerError['type'] as string | undefined;
if (typeof innerError['message'] === 'string') {
message = innerError['message'];
}
}
}
let userMessage = message;
if (httpStatusCode) userMessage = `[${httpStatusCode}] ${userMessage}`;
if (errorType && errorType !== 'error') userMessage = `${errorType}: ${userMessage}`;
if (apiError.requestID) userMessage += ` (Request ID: ${apiError.requestID})`;
const retryable = isClaudeErrorRetryable(error, httpStatusCode, errorType, apiError.headers ?? undefined);
const errorName = error.constructor?.name || 'AnthropicError';
return new LlumiverseError(`[${context.provider}] ${userMessage}`, retryable, context, error, httpStatusCode, errorName);
}
export function isClaudeErrorRetryable(
error: unknown,
httpStatusCode: number | undefined,
errorType: string | undefined,
headers?: Headers | undefined,
): boolean | undefined {
// Honour the server's explicit retry directive first (mirrors SDK shouldRetry logic).
const shouldRetryHeader = headers?.get('x-should-retry');
if (shouldRetryHeader === 'true') return true;
if (shouldRetryHeader === 'false') return false;
if (error instanceof APIUserAbortError) return false;
if (error instanceof RateLimitError) return true;
if (error instanceof InternalServerError) return true;
if (error instanceof APIConnectionTimeoutError) return true;
if (error instanceof BadRequestError) return false;
if (error instanceof AuthenticationError) return false;
if (error instanceof PermissionDeniedError) return false;
if (error instanceof NotFoundError) return false;
if (error instanceof ConflictError) return true; // SDK retries 409 (lock timeouts)
if (error instanceof UnprocessableEntityError) return false;
if (errorType === 'invalid_request_error') return false;
if (httpStatusCode !== undefined) {
if (httpStatusCode === 429 || httpStatusCode === 408 || httpStatusCode === 529) return true;
if (httpStatusCode >= 500 && httpStatusCode < 600) return true;
if (httpStatusCode >= 400 && httpStatusCode < 500) return false;
}
if (error instanceof APIConnectionError && !(error instanceof APIConnectionTimeoutError)) return true;
return undefined;
}