Files
simple-ai-provider/src/providers/claude.ts
Jan-Marlon Leibl b36d57711b refactor: pull validateConnection / validateModelName / error mapping into base
Form Template Method / Pull Up Method from the Refactoring Guru catalog.

The three SDK-based providers (Claude, OpenAI, Gemini) each had their own
near-identical implementations of:

  - validateConnection: try a tiny request, catch errors, map a handful
    of patterns, warn on the rest
  - validateModelName: iterate regex patterns, warn on no match
  - handle{X}Error: switch on HTTP status, map to AIProviderError with
    brand-flavored messages

These collapse into three protected hooks on BaseAIProvider:

  - getModelNamePatterns(): RegExp[]
  - sendValidationProbe(): Promise<void>
  - mapProviderError(error): AIProviderError | null
  - providerErrorMessages(): Partial<Record<number, string>>

The base owns the wrapping logic (warning, status -> error-type map,
common message patterns). Providers only contribute their unique parts.

Side effects:

  - normalizeError now consults mapProviderError before generic mapping,
    so provider-specific patterns work via the existing try/catch in
    BaseAIProvider.complete/stream. The per-provider try/catch wrappers
    around doComplete/doStream are removed.
  - The unknown-error message becomes `${providerName} error: ${msg}`
    instead of the hardcoded `Provider error:` / `Claude API error:` etc.
  - OpenWebUI's HTTP-based validateConnection is preserved (override),
    since its non-SDK shape doesn't fit the SDK probe pattern.
2026-05-21 13:43:07 +02:00

532 lines
17 KiB
TypeScript

/**
* Anthropic Claude AI Provider Implementation
*
* This module provides integration with Anthropic's Claude models through their official SDK.
* Claude is known for its advanced reasoning capabilities, long context length, and excellent
* performance on complex analytical tasks.
*
* Key Features:
* - Support for all Claude 3.x model variants (Opus, Sonnet, Haiku)
* - Native streaming support with real-time response chunks
* - Advanced system message handling (separate from conversation context)
* - Comprehensive error mapping for Anthropic-specific issues
* - Automatic retry logic with exponential backoff
*
* Claude-Specific Considerations:
* - System messages must be passed separately from conversation messages
* - Uses "input_tokens" and "output_tokens" terminology instead of "prompt_tokens"
* - Supports advanced features like function calling and vision (model-dependent)
* - Has specific rate limiting and token usage patterns
*
* @author Jan-Marlon Leibl
* @version 1.0.0
* @see https://docs.anthropic.com/claude/reference/
*/
import Anthropic from '@anthropic-ai/sdk';
import type {
AIProviderConfig,
CompletionParams,
CompletionResponse,
CompletionChunk,
ProviderInfo,
AIMessage
} from '../types/index.js';
import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_ANTHROPIC_VERSION,
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================
// TYPES AND INTERFACES
// ============================================================================
/**
* Configuration interface for Claude provider with Anthropic-specific options.
*
* @example
* ```typescript
* const config: ClaudeConfig = {
* apiKey: process.env.ANTHROPIC_API_KEY!,
* defaultModel: 'claude-3-5-sonnet-20241022',
* version: '2023-06-01',
* timeout: 60000,
* maxRetries: 3
* };
* ```
*/
export interface ClaudeConfig extends AIProviderConfig {
/**
* Default Claude model to use for requests.
*
* Recommended models:
* - 'claude-3-5-sonnet-20241022': Best balance of capability and speed
* - 'claude-3-5-haiku-20241022': Fastest responses, good for simple tasks
* - 'claude-3-opus-20240229': Most capable, best for complex reasoning
*
* @default 'claude-3-5-sonnet-20241022'
*/
defaultModel?: string;
/**
* Anthropic API version header value.
*
* This ensures compatibility with specific API features and response formats.
* Check Anthropic's documentation for the latest version.
*
* @default '2023-06-01'
* @see https://docs.anthropic.com/claude/reference/versioning
*/
version?: string;
}
/**
* Internal interface for processed message structure.
* Claude requires system messages to be separated from conversation flow.
*/
interface ProcessedMessages {
/** Combined system message content (null if no system messages) */
system: string | null;
/** Conversation messages (user/assistant only) */
messages: AIMessage[];
}
// ============================================================================
// CLAUDE PROVIDER IMPLEMENTATION
// ============================================================================
/**
* Anthropic Claude AI provider implementation.
*
* This class handles all interactions with Anthropic's Claude models through
* their official TypeScript SDK. It provides optimized handling of Claude's
* unique features like separate system message processing and advanced
* streaming capabilities.
*
* Usage Pattern:
* 1. Create instance with configuration
* 2. Call initialize() to set up client and validate connection
* 3. Use complete() or stream() for text generation
* 4. Handle any AIProviderError exceptions appropriately
*
* @example
* ```typescript
* const claude = new ClaudeProvider({
* apiKey: process.env.ANTHROPIC_API_KEY!,
* defaultModel: 'claude-3-5-sonnet-20241022'
* });
*
* await claude.initialize();
*
* const response = await claude.complete({
* messages: [
* { role: 'system', content: 'You are a helpful assistant.' },
* { role: 'user', content: 'Explain quantum computing.' }
* ],
* maxTokens: 1000,
* temperature: 0.7
* });
* ```
*/
export class ClaudeProvider extends BaseAIProvider {
// ========================================================================
// INSTANCE PROPERTIES
// ========================================================================
/** Anthropic SDK client instance (initialized during doInitialize) */
private client: Anthropic | null = null;
/** Default model identifier for requests */
private readonly defaultModel: string;
/** API version for Anthropic requests */
private readonly version: string;
// ========================================================================
// CONSTRUCTOR
// ========================================================================
/**
* Creates a new Claude provider instance.
*
* @param config - Claude-specific configuration options
* @throws {AIProviderError} If configuration validation fails
*/
constructor(config: ClaudeConfig) {
super(config);
this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
// Validate model name format
this.validateModelName(this.defaultModel);
}
// ========================================================================
// PROTECTED TEMPLATE METHOD IMPLEMENTATIONS
// ========================================================================
/**
* Initializes the Claude provider by setting up the Anthropic client.
*
* This method:
* 1. Creates the Anthropic SDK client with configuration
* 2. Tests the connection with a minimal API call
* 3. Validates API key permissions and model access
*
* @protected
* @throws {Error} If client creation or connection validation fails
*/
protected async doInitialize(): Promise<void> {
try {
this.client = new Anthropic({
apiKey: this.config.apiKey,
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
maxRetries: this.config.maxRetries,
defaultHeaders: {
'anthropic-version': this.version,
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
}
});
await this.validateConnection();
} catch (error) {
this.client = null;
throw new AIProviderError(
`Failed to initialize Claude provider: ${(error as Error).message}`,
AIErrorType.AUTHENTICATION,
undefined,
error as Error
);
}
}
/**
* Generates a text completion using Claude's message API.
*
* This method:
* 1. Processes messages to separate system content
* 2. Builds optimized request parameters
* 3. Makes API call with error handling
* 4. Formats response to standard interface
*
* @protected
* @param params - Validated completion parameters
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (!this.client) {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
}
const { system, messages } = this.processMessages(params.messages);
const requestParams = this.buildRequestParams(params, system, messages, false);
const response = await this.client.messages.create(requestParams);
return this.formatCompletionResponse(response);
}
/**
* Generates a streaming text completion using Claude's streaming API.
*
* This method:
* 1. Processes messages and builds streaming request
* 2. Handles real-time stream chunks from Anthropic
* 3. Yields formatted chunks with proper completion tracking
* 4. Provides final usage statistics when stream completes
*
* @protected
* @param params - Validated completion parameters
* @returns AsyncIterable yielding completion chunks
* @throws {Error} If streaming request fails
*/
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
if (!this.client) {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
}
const { system, messages } = this.processMessages(params.messages);
const requestParams = this.buildRequestParams(params, system, messages, true);
const stream = await this.client.messages.create(requestParams);
yield* this.processStreamChunks(stream);
}
// ========================================================================
// PUBLIC INTERFACE METHODS
// ========================================================================
/**
* Returns comprehensive information about the Claude provider.
*
* @returns Provider information including models, capabilities, and limits
*/
public getInfo(): ProviderInfo {
return {
name: 'Claude',
version: '1.0.0',
models: [
'claude-3-5-sonnet-20241022', // Latest and most balanced
'claude-3-5-haiku-20241022', // Fastest response times
'claude-3-opus-20240229', // Most capable reasoning
'claude-3-sonnet-20240229', // Previous generation balanced
'claude-3-haiku-20240307' // Previous generation fast
],
maxContextLength: 200000, // 200K tokens for most models
supportsStreaming: true,
capabilities: {
vision: true, // Claude 3+ supports image analysis
functionCalling: true, // Tool use capability
systemMessages: true, // Native system message support
reasoning: true, // Advanced reasoning capabilities
codeGeneration: true, // Excellent at code tasks
analysis: true // Strong analytical capabilities
}
};
}
// ========================================================================
// PRIVATE UTILITY METHODS
// ========================================================================
protected override getModelNamePatterns(): RegExp[] {
return [
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
/^claude-instant-[0-9.]+$/,
/^claude-[0-9.]+$/
];
}
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
await this.client.messages.create({
model: this.defaultModel,
max_tokens: 1,
messages: [{ role: 'user', content: VALIDATION_PROMPT }]
});
}
protected override providerErrorMessages(): Partial<Record<number, string>> {
return {
401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
404: 'Model not found. The specified Claude model may not be available to your account.',
429: 'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.',
500: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
502: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
503: 'Anthropic service temporarily unavailable. Please try again in a few moments.'
};
}
protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
const status = error.status || error.statusCode;
if (status === 400) {
if (message.includes('max_tokens')) {
return new AIProviderError(
'Max tokens value is invalid. Must be between 1 and model limit.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('model')) {
return new AIProviderError(
'Invalid model specified. Please check model availability.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
}
return null;
}
/**
* Processes messages to separate system content from conversation.
*
* Claude requires system messages to be passed separately from the
* conversation messages array. This method handles that transformation.
*
* @private
* @param messages - Input messages array
* @returns Processed messages with separated system content
*/
private processMessages(messages: AIMessage[]): ProcessedMessages {
let systemContent: string | null = null;
const conversationMessages: AIMessage[] = [];
for (const message of messages) {
if (message.role === 'system') {
// Combine multiple system messages with double newlines
if (systemContent) {
systemContent += '\n\n' + message.content;
} else {
systemContent = message.content;
}
} else {
// Only user and assistant messages go in conversation
conversationMessages.push(message);
}
}
// Validate conversation structure
if (conversationMessages.length === 0) {
throw new AIProviderError(
'At least one user or assistant message is required',
AIErrorType.INVALID_REQUEST
);
}
return {
system: systemContent,
messages: conversationMessages
};
}
/**
* Builds optimized request parameters for Anthropic API.
*
* @private
* @param params - Input completion parameters
* @param system - Processed system content
* @param messages - Processed conversation messages
* @param stream - Whether to enable streaming
* @returns Formatted request parameters
*/
private buildRequestParams(
params: CompletionParams,
system: string | null,
messages: AIMessage[],
stream: boolean
) {
return {
model: params.model || this.defaultModel,
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop_sequences: params.stopSequences,
system: system || undefined,
messages: messages.map(msg => ({
role: msg.role as 'user' | 'assistant',
content: msg.content
})),
stream
};
}
/**
* Processes streaming response chunks from Anthropic API.
*
* @private
* @param stream - Anthropic streaming response
* @returns AsyncIterable of formatted completion chunks
*/
private async *processStreamChunks(stream: any): AsyncIterable<CompletionChunk> {
let messageId = '';
let totalInputTokens = 0;
let totalOutputTokens = 0;
try {
for await (const chunk of stream) {
// Handle different chunk types
switch (chunk.type) {
case 'message_start':
messageId = chunk.message.id;
totalInputTokens = chunk.message.usage?.input_tokens || 0;
break;
case 'content_block_delta':
if (chunk.delta?.type === 'text_delta') {
yield {
content: chunk.delta.text,
isComplete: false,
id: messageId
};
}
break;
case 'message_delta':
if (chunk.usage?.output_tokens) {
totalOutputTokens = chunk.usage.output_tokens;
}
break;
case 'message_stop':
// Final chunk with complete usage information
yield {
content: '',
isComplete: true,
id: messageId,
usage: {
promptTokens: totalInputTokens,
completionTokens: totalOutputTokens,
totalTokens: totalInputTokens + totalOutputTokens
}
};
return;
}
}
} catch (error) {
// Handle streaming errors gracefully
throw new AIProviderError(
`Streaming interrupted: ${(error as Error).message}`,
AIErrorType.NETWORK,
undefined,
error as Error
);
}
}
/**
* Formats Anthropic's completion response to our standard interface.
*
* @private
* @param response - Raw Anthropic API response
* @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected
*/
private formatCompletionResponse(response: any): CompletionResponse<string> {
// Extract text content from response blocks
const content = response.content
?.filter((block: any) => block.type === 'text')
?.map((block: any) => block.text)
?.join('') || '';
if (!content) {
throw new AIProviderError(
'No text content found in Claude response',
AIErrorType.UNKNOWN
);
}
return {
content,
model: response.model,
usage: {
promptTokens: response.usage?.input_tokens || 0,
completionTokens: response.usage?.output_tokens || 0,
totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
},
id: response.id,
metadata: {
stopReason: response.stop_reason,
stopSequence: response.stop_sequence || null,
created: Date.now() // Anthropic doesn't provide timestamp
}
};
}
}