import { BaseHandler } from './BaseHandler'; import { IPromptOptions, IPromptGenericConfig, ModuleOptions } from '../../types'; import { libx } from 'libx.js/build/bundles/essentials.js'; import { Streams } from 'libx.js/build/modules/Streams'; export class AnthropicHandler extends BaseHandler { private baseUrl = 'https://api.anthropic.com/v1'; private version = '2023-06-01'; constructor(options: ModuleOptions) { super(options); } async execute(isStream: boolean, options: IPromptOptions): Promise { const isPlain = options.config.plain ?? false; try { const config = this.getConfig(options.config); const apiKey = this.getApiKey('anthropic'); if (!apiKey) { throw new Error('Anthropic API key not configured'); } // Prepare messages (Anthropic doesn't support system in messages array) const messages = this.prepareMessages(options); const payload: any = { model: config.model, messages, max_tokens: config.max_tokens, stream: isStream, }; // Add system prompt separately if (options.systemPrompt) { payload.system = options.systemPrompt; } // Handle temperature with newer models if (config.temperature !== undefined) { payload.temperature = config.temperature; // Newer models don't support top_p with temperature if (!config.noTopPWithTemp && config.top_p !== undefined) { payload.top_p = config.top_p; } } if (config.stop) { payload.stop_sequences = config.stop; } if (isStream) { return await this.executeStream(payload, apiKey, isPlain); } else { return await this.executeSync(payload, apiKey, isPlain); } } catch (error) { return this.handleError(error, isPlain); } } getConfig(generalConfig: IPromptGenericConfig): any { const rawModel = generalConfig.model || 'claude-3-5-sonnet-20241022'; const model = this.normalizeModelName(rawModel, 'anthropic'); // Check for newer models that don't support top_p with temperature const isNewerModel = model?.includes('claude-3') || model?.includes('claude-4'); return { model, temperature: generalConfig.temperature ?? 0.7, max_tokens: generalConfig.maxTokens || 4096, top_p: generalConfig.topP, stop: generalConfig.stopSequences, noTopPWithTemp: isNewerModel, }; } private prepareMessages(options: IPromptOptions): any[] { const messages: any[] = []; if (options.messages) { for (const msg of options.messages) { // Skip system messages (handled separately in Anthropic API) if (msg.role === 'system') continue; const message: any = { role: msg.role, content: msg.content }; // Handle multi-modal (image input) if (msg.files && msg.files.length > 0) { message.content = [ { type: 'text', text: msg.content } ]; for (const file of msg.files) { if (file.type?.startsWith('image/')) { // Anthropic requires base64 images message.content.push({ type: 'image', source: { type: file.url.startsWith('data:') ? 'base64' : 'url', media_type: file.type, data: file.url.startsWith('data:') ? file.url.split(',')[1] : file.url } }); } } } messages.push(message); } } return messages; } private async executeStream(payload: any, apiKey: string, isPlain: boolean): Promise { const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const url = `${this.baseUrl}/messages`; libx.log.v('AnthropicHandler: streaming request to', url); let faultyChunk = ''; const promise = Streams.getStream( url, async (chunk: string) => { try { // Combine with any partial leftover let incoming = faultyChunk + chunk; faultyChunk = ''; // Split by double newlines (SSE event separator) const events = incoming.split(/\n\n+/); // Keep the last part if it doesn't end with a separator (partial chunk) if (!incoming.endsWith('\n\n')) { faultyChunk = events.pop() || ''; } for (const evt of events) { const lines = evt.split('\n').map(l => l.trim()).filter(Boolean); if (lines.length === 0) continue; // We only care about content deltas const isDelta = lines.some(l => l.startsWith('event:') && l.includes('content_block_delta')); if (!isDelta) continue; // There may be multiple data lines; process each for (let line of lines) { if (!line.startsWith('data:')) continue; const jsonStr = line.slice(5).trim(); if (!jsonStr) continue; try { const data = JSON.parse(jsonStr); const delta = data?.delta?.text || data?.content_block?.delta?.text; if (typeof delta === 'string' && delta.length > 0) { await this.writeStreamChunk(writer, encoder, delta, isPlain); } } catch (e) { // If not full JSON, accumulate for next pass faultyChunk += line + '\n'; } } } } catch (err) { libx.log.w('AnthropicHandler: chunk processing error', err); } }, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': this.version }, body: payload, encoding: 'utf-8', useEventBuffering: false, } ); promise.then(() => { libx.log.v('AnthropicHandler: stream completed'); if (!isPlain) { writer.write(encoder.encode('data: [DONE]\n\n')); } writer.close(); }).catch((error) => { libx.log.e('AnthropicHandler: stream error', error); if (isPlain) { writer.write(encoder.encode(`\nError: ${error.message}`)); } else { writer.write(encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)); } writer.close(); }); return readable; } private async executeSync(payload: any, apiKey: string, isPlain: boolean): Promise { const url = `${this.baseUrl}/messages`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': this.version }, body: JSON.stringify(payload) }); if (!response.ok) { const error = await response.text(); throw new Error(`Anthropic API error: ${error}`); } const data = await response.json(); const content = data.content?.[0]?.text || ''; const metadata = { model: data.model, usage: data.usage, stop_reason: data.stop_reason }; return this.writeSyncResponse(content, metadata, isPlain); } }