llmplug
Version:
A library to easily integrate various LLM models and vendors into applications, with advanced features.
320 lines (293 loc) • 14.6 kB
JavaScript
import Anthropic from '@anthropic-ai/sdk';
import { BaseProvider } from './baseProvider.js';
import { LLMPlugConfigurationError, LLMPlugRequestError } from '../utils/errors.js';
export class AnthropicProvider extends BaseProvider {
constructor(config = {}) {
super(config);
this.providerName = "Anthropic";
try {
this.apiKey = this._getApiKey('ANTHROPIC_API_KEY');
this.client = new Anthropic({ apiKey: this.apiKey });
} catch (error) {
if (error instanceof LLMPlugConfigurationError) throw error;
throw new LLMPlugConfigurationError(`Anthropic client initialization failed: ${error.message}`, this.providerName, error);
}
this.defaultModel = config.defaultModel || 'claude-3-haiku-20240307';
}
/**
* Prepares messages for the Anthropic API.
* - Handles system prompts.
* - Converts LLMPlug's ChatMessage format to Anthropic's format.
* - Automatically fetches and base64 encodes image URLs.
* - Formats tool calls and tool responses.
* @param {import('../baseProvider.js').ChatMessage[]} messages - Array of chat messages.
* @returns {Promise<{ systemPrompt?: string, anthropicMessages: Anthropic.Messages.MessageParam[] }>}
* @protected
*/
async _prepareMessages(messages) {
let systemPrompt;
const anthropicMessages = [];
for (const msg of messages) {
if (msg.role === 'system') {
// System prompt content should be a simple string for Anthropic.
if (typeof msg.content === 'string') {
systemPrompt = msg.content;
} else if (Array.isArray(msg.content) && msg.content.length > 0 && msg.content[0].type === 'text') {
systemPrompt = msg.content.map(c => c.text || '').join('\n'); // Concatenate text parts if system prompt is array
}
continue; // System prompt handled, move to next message
}
// Process user, assistant, and tool messages
const role = msg.role === 'tool' ? 'user' : msg.role; // Anthropic tool results are user messages
const contentBlocks = [];
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text') {
contentBlocks.push({ type: 'text', text: part.text });
} else if (part.type === 'image_url') {
// LLMPlug AUTOMATICALLY handles fetching and base64 encoding here
const { base64Data, mimeType } = await this._fetchAndBase64Image(part.image_url.url);
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: mimeType,
data: base64Data,
}
});
} else if (part.type === 'tool_output' && msg.role === 'tool') {
// This content part is for a 'tool' role message. It's handled by the tool_result structure below.
// We'll add it as a specific tool_result block.
// Anthropic expects tool_result content to be a string or JSON object.
// For simplicity, we'll stringify if it's not already a string.
const outputContent = typeof part.content === 'string' ? part.content : JSON.stringify(part.content);
contentBlocks.push({
type: 'tool_result',
tool_use_id: msg.tool_call_id,
content: outputContent,
// is_error: part.is_error, // Optional: if you add is_error to ToolOutputContent
});
} else if (part.type === 'tool_code' ) {
// This is usually part of an assistant message, indicating tool call arguments.
// Anthropic handles this via the 'tool_use' block for assistant, not as separate content part usually.
// For now, if it exists, we'll add its text.
contentBlocks.push({ type: 'text', text: part.text });
}
}
} else if (typeof msg.content === 'string') {
contentBlocks.push({ type: 'text', text: msg.content });
}
// Handle assistant's decision to use tools
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
msg.tool_calls.forEach(tc => {
contentBlocks.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments), // Anthropic expects parsed JSON object for input
});
});
}
// Ensure that if the role is 'tool', contentBlocks should primarily contain 'tool_result'
if (msg.role === 'tool') {
const toolResultBlock = contentBlocks.find(cb => cb.type === 'tool_result');
if (!toolResultBlock) {
throw new LLMPlugRequestError(`Tool message (role 'tool') with tool_call_id '${msg.tool_call_id}' must contain a 'tool_output' content part.`, this.providerName);
}
// For 'tool' role, Anthropic expects the message role to be 'user' and content to be the tool_result blocks.
anthropicMessages.push({ role: 'user', content: [toolResultBlock] });
} else if (contentBlocks.length > 0) {
// For user or assistant messages with content (text, image, or tool_use intent)
anthropicMessages.push({ role: role, content: contentBlocks });
} else if (msg.role === 'assistant' && (!msg.tool_calls || msg.tool_calls.length === 0) && msg.content === null) {
// Assistant message with no text and no tool_calls (e.g. if only finish_reason was 'tool_calls' but calls array was empty somehow)
// This is unusual but we can represent it as an empty content assistant message.
anthropicMessages.push({ role: 'assistant', content: [] });
}
}
if (!systemPrompt && anthropicMessages.length === 0) {
throw new LLMPlugRequestError("Anthropic API requires at least one user message or a system prompt to proceed.", this.providerName);
}
return { systemPrompt, anthropicMessages };
}
/**
* Converts a simple prompt string or an array of ChatMessages into a valid ChatMessage array.
* @param {string | import('../baseProvider.js').ChatMessage[]} input
* @returns {import('../baseProvider.js').ChatMessage[]}
* @protected
*/
_prepareInputAsMessages(input) {
if (typeof input === 'string') {
return [{ role: 'user', content: input }];
}
if (Array.isArray(input)) {
return input; // Assume it's already in ChatMessage[] format
}
throw new LLMPlugRequestError("Invalid input type. Must be a string or an array of ChatMessage objects.", this.providerName);
}
/**
* @param {string | import('../baseProvider.js').ChatMessage[]} input
* @param {import('../baseProvider.js').GenerationOptions} [options={}]
* @returns {Promise<import('../baseProvider.js').GenerationResult>}
*/
async generate(input, options = {}) {
const messages = this._prepareInputAsMessages(input);
return this.chat(messages, options);
}
/**
* @param {import('../baseProvider.js').ChatMessage[]} messages
* @param {import('../baseProvider.js').GenerationOptions} [options={}]
* @returns {Promise<import('../baseProvider.js').GenerationResult>}
*/
async chat(messages, options = {}) {
const model = options.model || this.defaultModel;
const { systemPrompt, anthropicMessages } = await this._prepareMessages(messages);
const requestParams = {
model: model,
messages: anthropicMessages,
max_tokens: options.maxTokens || 1024, // Anthropic requires max_tokens
temperature: options.temperature,
stop_sequences: options.stopSequences,
tools: options.tools?.map(tool => ({
name: tool.function.name,
description: tool.function.description,
input_schema: tool.function.parameters, // Anthropic uses input_schema
})),
...(systemPrompt && { system: systemPrompt }), // Conditionally add system prompt
...options.extraParams,
};
try {
const response = await this.client.messages.create(requestParams);
const textContent = response.content.filter(block => block.type === 'text').map(block => block.text).join('').trim() || null;
const toolCalls = response.content
.filter(block => block.type === 'tool_use')
.map(block => ({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: JSON.stringify(block.input || {}), // Ensure input is stringified
},
}));
const usage = {
promptTokens: response.usage?.input_tokens,
completionTokens: response.usage?.output_tokens,
totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0),
};
// Anthropic's stop_reason maps to finishReason
// e.g., "end_turn", "max_tokens", "stop_sequence", "tool_use"
const finishReason = response.stop_reason;
return {
text: textContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage: usage,
finishReason: finishReason,
rawResponse: response,
};
} catch (error) {
const errorMessage = error.error?.message || error.message || "Unknown Anthropic API error";
throw new LLMPlugRequestError(`Anthropic API chat request failed: ${errorMessage}`, this.providerName, error);
}
}
/**
* @param {string | import('../baseProvider.js').ChatMessage[]} input
* @param {import('../baseProvider.js').GenerationOptions} [options={}]
* @returns {AsyncIterable<import('../baseProvider.js').GenerationStreamChunk>}
*/
async *generateStream(input, options = {}) {
const messages = this._prepareInputAsMessages(input);
yield* this.chatStream(messages, options);
}
/**
* @param {import('../baseProvider.js').ChatMessage[]} messages
* @param {import('../baseProvider.js').GenerationOptions} [options={}]
* @returns {AsyncIterable<import('../baseProvider.js').GenerationStreamChunk>}
*/
async *chatStream(messages, options = {}) {
const model = options.model || this.defaultModel;
const { systemPrompt, anthropicMessages } = await this._prepareMessages(messages);
const requestParams = {
model: model,
messages: anthropicMessages,
max_tokens: options.maxTokens || 1024,
temperature: options.temperature,
stop_sequences: options.stopSequences,
tools: options.tools?.map(tool => ({
name: tool.function.name,
description: tool.function.description,
input_schema: tool.function.parameters,
})),
stream: true,
...(systemPrompt && { system: systemPrompt }),
...options.extraParams,
};
try {
const stream = this.client.messages.stream(requestParams); // Use .stream() for Anthropic SDK
// Accumulators for tool call arguments if they stream partially
// (Anthropic usually sends tool_use input fully in content_block_start)
const streamingToolCallArgs = {};
for await (const event of stream) {
const chunkData = { rawChunk: event };
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
chunkData.text = event.delta.text;
} else if (event.type === 'content_block_start' && event.delta?.type === 'input_json_delta') {
// This event type is for Claude 3.5 Sonnet streaming tool inputs
if (event.content_block.type === 'tool_use') {
const toolUseId = event.content_block.id;
if (!streamingToolCallArgs[toolUseId]) {
streamingToolCallArgs[toolUseId] = {
id: toolUseId,
name: event.content_block.name,
arguments: ''
};
}
streamingToolCallArgs[toolUseId].arguments += event.delta.partial_json;
}
} else if (event.type === 'content_block_start' && event.content_block.type === 'tool_use') {
// For models older than Claude 3.5 Sonnet, input comes fully here
chunkData.toolCalls = [{
id: event.content_block.id,
type: 'function',
function: {
name: event.content_block.name,
arguments: JSON.stringify(event.content_block.input || {}),
}
}];
} else if (event.type === 'message_delta' && event.delta.stop_reason) {
// Claude 3.5 Sonnet sends stop_reason and usage in message_delta
chunkData.finishReason = event.delta.stop_reason;
if (event.usage) { // Check if usage is present on this specific event
chunkData.usage = {
output_tokens: event.usage.output_tokens,
// input_tokens usually comes in message_start
};
}
} else if (event.type === 'message_start') {
// Contains input_tokens
if (event.message.usage) {
chunkData.usage = { promptTokens: event.message.usage.input_tokens };
}
} else if (event.type === 'message_stop') {
// This event signals the end of the stream.
// For Claude 3.5 Sonnet, tool_calls derived from input_json_delta should be finalized here
const finalizedToolCalls = Object.values(streamingToolCallArgs).map(tc => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments }
}));
if (finalizedToolCalls.length > 0) {
chunkData.toolCalls = finalizedToolCalls;
}
// Older models might send final usage/stop_reason here.
// For Claude 3.5 Sonnet, these are in message_delta.
// We need to check the Anthropic API documentation for which models use which event types for final info.
// Let's assume `message_delta` is primary for new models.
}
yield chunkData;
}
} catch (error) {
const errorMessage = error.error?.message || error.message || "Unknown Anthropic API stream error";
throw new LLMPlugRequestError(`Anthropic API chat stream failed: ${errorMessage}`, this.providerName, error);
}
}
}