llmplug
Version:
A library to easily integrate various LLM models and vendors into applications, with advanced features.
328 lines (296 loc) • 15.9 kB
JavaScript
import fetch from 'node-fetch';
import { fileTypeFromBuffer } from 'file-type'; // Import file-type
import { LLMPlugError, LLMPlugConfigurationError, LLMPlugRequestError } from '../utils/errors.js';
/**
* @typedef {'text' | 'image_url' | 'tool_code' | 'tool_output'} ContentType
*/
/**
* @typedef {Object} ImageUrlContent
* @property {string} type - Must be 'image_url'.
* @property {Object} image_url
* @property {string} image_url.url - The URL of the image. Can be data URI (base64 encoded) or http(s) URL.
* @property {'low' | 'high' | 'auto'} [image_url.detail='auto'] - Detail level for vision models.
*/
/**
* @typedef {Object} TextContent
* @property {string} type - Must be 'text'.
* @property {string} text - The text content.
*/
/**
* @typedef {Object} ToolCodeContent
* @property {string} type - Must be 'tool_code'.
* @property {string} text - The code generated by the assistant for tool use (e.g., JSON string for function args).
*/
/**
* @typedef {Object} ToolOutputContent
* @property {string} type - Must be 'tool_output'.
* @property {string} tool_call_id - The ID of the tool call this output corresponds to.
* @property {any} content - The output content from the tool. Can be string, JSON, etc.
*/
/**
* @typedef {TextContent | ImageUrlContent | ToolCodeContent | ToolOutputContent} MessageContentPart
*/
/**
* Represents a single call to a tool by the model.
* @typedef {Object} ToolCall
* @property {string} id - The ID of the tool call.
* @property {'function'} type - The type of the tool call (currently only 'function').
* @property {Object} function
* @property {string} function.name - The name of the function to call.
* @property {string} function.arguments - A JSON string of the arguments to call the function with.
*/
/**
* @typedef {Object} ChatMessage
* @property {'system' | 'user' | 'assistant' | 'tool'} role
* @property {string | MessageContentPart[]} content - String for simple text, array for multimodal or structured content.
* @property {string} [name] - Optional name of the participant (e.g., for 'tool' role, the name of the function).
* @property {ToolCall[]} [tool_calls] - Array of tool calls made by the model (for assistant role).
* @property {string} [tool_call_id] - ID of the tool call this message is a response to (for 'tool' role).
*/
/**
* Defines the schema for function parameters, typically using JSON Schema.
* @typedef {Object} FunctionParameters
* @property {'object'} type - The top-level type must be 'object'.
* @property {Object.<string, any>} properties - An object defining the properties (parameters) of the function.
* @property {string[]} [required] - An array of names of required properties.
*/
/**
* Defines a tool (typically a function) that the model can call.
* @typedef {Object} ToolDefinition
* @property {'function'} type - The type of tool, currently only 'function' is supported.
* @property {Object} function
* @property {string} function.name - The name of the function to be called.
* @property {string} [function.description] - A description of what the function does, used by the model to decide when to call it.
* @property {FunctionParameters} [function.parameters] - The parameters the function accepts, described as a JSON Schema object.
*/
/**
* Represents token usage information for a generation request.
* @typedef {Object} UsageData
* @property {number} [promptTokens] - Number of tokens in the prompt/input.
* @property {number} [completionTokens] - Number of tokens in the generated completion.
* @property {number} [totalTokens] - Total number of tokens used.
*/
/**
* Specifies the desired format for the model's response.
* @typedef {Object} ResponseFormat
* @property {'text' | 'json_object'} type - The desired response format.
*/
/**
* Options for controlling the generation process.
* @typedef {Object} GenerationOptions
* @property {string} [model] - The specific model to use (overrides provider's default).
* @property {number} [temperature] - Sampling temperature (0.0 to 2.0). Higher values make output more random.
* @property {number} [maxTokens] - Maximum number of tokens to generate in the completion.
* @property {string[]} [stopSequences] - Sequences where the API will stop generating further tokens.
* @property {ResponseFormat} [responseFormat] - Desired format of the response (e.g., for JSON mode).
* @property {ToolDefinition[]} [tools] - Array of tool definitions available to the model.
* @property {'auto' | 'none' | {type: 'function', function: {name: string}}} [toolChoice='auto'] - Controls how the model uses tools.
* @property {any} [extraParams] - Any other provider-specific parameters not covered by standard options.
*/
/**
* The result of a non-streaming generation request.
* @typedef {Object} GenerationResult
* @property {string | null} text - The generated text content. Null if only tool calls are made.
* @property {UsageData} [usage] - Information about token usage.
* @property {string} [finishReason] - The reason the model stopped generating (e.g., 'stop', 'length', 'tool_calls').
* @property {ToolCall[]} [toolCalls] - Array of tool calls made by the model.
* @property {any} [rawResponse] - The raw response object from the underlying provider SDK for debugging or extra data.
*/
/**
* A chunk of data from a streaming generation request.
* @typedef {Object} GenerationStreamChunk
* @property {string} [text] - Partial generated text content for this chunk.
* @property {ToolCall[]} [toolCalls] - Partial or complete tool calls for this chunk.
* @property {string} [finishReason] - The reason the stream finished (usually in the last chunk).
* @property {UsageData} [usage] - Usage data for the entire response (often at the end of stream or in the last chunk).
* @property {any} [rawChunk] - The raw chunk object from the underlying provider SDK for debugging or extra data.
*/
export class BaseProvider {
constructor(config) {
this.config = config;
this.providerName = "BaseProvider"; // Should be overridden by subclasses
// Max image size to fetch (e.g., 20MB) to prevent abuse. Providers have their own limits too.
this.maxImageFetchSize = config.maxImageFetchSize || 20 * 1024 * 1024;
}
/**
* Processes an image URL: fetches remote images and converts to base64,
* or passes through data URIs.
* @param {string} imageUrl - The URL of the image (http/https or data URI).
* @returns {Promise<{base64Data: string, mimeType: string}>}
* @throws {LLMPlugRequestError} if fetching or processing fails.
* @protected
*/
async _fetchAndBase64Image(imageUrl) {
if (imageUrl.startsWith('data:')) {
try {
const [header, base64Data] = imageUrl.split(',');
if (!header || !base64Data) {
throw new Error('Invalid data URI format.');
}
// More robust regex to capture various image MIME types
const mimeTypeMatch = header.match(/^data:(image\/[^;]+);base64$/i);
if (!mimeTypeMatch || !mimeTypeMatch[1]) {
throw new Error('Could not extract image MIME type from data URI. Ensure format is data:image/your_format;base64,...');
}
return { base64Data, mimeType: mimeTypeMatch[1] };
} catch (error) {
throw new LLMPlugRequestError(`Failed to parse data URI: ${error.message}`, this.providerName, error);
}
} else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.fetchTimeout || 30000); // 30s timeout
const response = await fetch(imageUrl, {
headers: { 'Accept': 'image/*' },
signal: controller.signal,
// 'size' option is not standard for fetch API, but some node-fetch versions might use it.
// Content length check below is more reliable.
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error ${response.status} when fetching image.`);
}
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > this.maxImageFetchSize) {
throw new Error(`Image content-length (${contentLength} bytes) exceeds maximum allowed size (${this.maxImageFetchSize} bytes).`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Fetched image is empty (0 bytes).');
}
if (buffer.byteLength > this.maxImageFetchSize) {
throw new Error(`Fetched image data size (${buffer.byteLength} bytes) exceeds maximum allowed size (${this.maxImageFetchSize} bytes).`);
}
const typeInfo = await fileTypeFromBuffer(buffer);
let mimeType;
if (typeInfo && typeInfo.mime.startsWith('image/')) {
mimeType = typeInfo.mime;
} else {
// Fallback to Content-Type header if file-type fails or gives non-image
const contentTypeHeader = response.headers.get('content-type');
if (contentTypeHeader && contentTypeHeader.startsWith('image/')) {
mimeType = contentTypeHeader.split(';')[0].trim(); // Get only the mime type part
} else {
throw new Error('Fetched file is not a recognized image type, or Content-Type header is missing/invalid.');
}
}
const base64Data = Buffer.from(buffer).toString('base64');
return { base64Data, mimeType };
} catch (error) {
if (error.name === 'AbortError') {
throw new LLMPlugRequestError(`Image fetch timed out from URL (${imageUrl})`, this.providerName, error);
}
throw new LLMPlugRequestError(`Failed to fetch or process image from URL (${imageUrl}): ${error.message}`, this.providerName, error);
}
} else {
throw new LLMPlugRequestError(`Unsupported image URL scheme: ${imageUrl}. Must be http(s) or data URI.`, this.providerName);
}
}
/**
* Generates a text completion based on a prompt or a series of messages.
* This is a convenience method that often delegates to `chat`.
* @param {string | ChatMessage[]} input - The input prompt string or an array of chat messages.
* @param {GenerationOptions} [options={}] - Options for generation.
* @returns {Promise<GenerationResult>} The generated text and metadata.
* @throws {LLMPlugError} If the method is not implemented or an API error occurs.
*/
async generate(input, options = {}) {
throw new LLMPlugError(`'generate' method not implemented for ${this.providerName}`, this.providerName);
}
/**
* Generates a chat completion based on a series of messages.
* @param {ChatMessage[]} messages - An array of chat messages.
* @param {GenerationOptions} [options={}] - Options for generation.
* @returns {Promise<GenerationResult>} The assistant's reply and metadata.
* @throws {LLMPlugError} If the method is not implemented or an API error occurs.
*/
async chat(messages, options = {}) {
throw new LLMPlugError(`'chat' method not implemented for ${this.providerName}`, this.providerName);
}
/**
* Generates a text completion and streams the response.
* This is a convenience method that often delegates to `chatStream`.
* @param {string | ChatMessage[]} input - The input prompt string or an array of chat messages.
* @param {GenerationOptions} [options={}] - Options for generation.
* @returns {AsyncIterable<GenerationStreamChunk>} An async iterable of response chunks.
* @throws {LLMPlugError} If the method is not implemented or an API error occurs.
*/
async *generateStream(input, options = {}) {
throw new LLMPlugError(`'generateStream' method not implemented for ${this.providerName}`, this.providerName);
}
/**
* Generates a chat completion and streams the response.
* @param {ChatMessage[]} messages - An array of chat messages.
* @param {GenerationOptions} [options={}] - Options for generation.
* @returns {AsyncIterable<GenerationStreamChunk>} An async iterable of response chunks.
* @throws {LLMPlugError} If the method is not implemented or an API error occurs.
*/
async *chatStream(messages, options = {}) {
throw new LLMPlugError(`'chatStream' method not implemented for ${this.providerName}`, this.providerName);
}
/**
* A common method to get the API key, prioritizing direct config over environment variables.
* @param {string} envVarName - The environment variable name for the API key.
* @param {string} [configKeyName='apiKey'] - The key name in the constructor's config object.
* @returns {string} The API key.
* @throws {LLMPlugConfigurationError} If the API key is not found.
* @protected
*/
_getApiKey(envVarName, configKeyName = 'apiKey') {
const key = this.config[configKeyName] || process.env[envVarName];
if (!key) {
throw new LLMPlugConfigurationError(
`API key not found. Set it in config.${configKeyName} or as ${envVarName} environment variable.`,
this.providerName
);
}
return key;
}
/**
* Helper to normalize content for providers. This base version passes content parts through.
* Specific providers might override or use this to further transform content.
* The image URL objects are passed as is, because the _fetchAndBase64Image call
* will happen within the provider's _prepareMessages method if needed.
* @param {string | MessageContentPart[]} content - The content to normalize.
* @returns {string | MessageContentPart[]} Provider-agnostic normalized content (usually).
* @protected
*/
_normalizeContent(content) {
if (typeof content === 'string') {
return content; // Simple text content
}
if (Array.isArray(content)) {
// Process each part. For this base method, we're mostly ensuring structure.
return content.map(part => {
if (part.type === 'text') {
return { type: 'text', text: part.text };
} else if (part.type === 'image_url') {
// Pass the image_url object as is.
// The specific provider's _prepareMessages will call _fetchAndBase64Image if necessary.
if (!part.image_url || typeof part.image_url.url !== 'string') {
throw new LLMPlugError('Invalid image_url structure: `url` property is missing or not a string.', this.providerName);
}
return {
type: 'image_url',
image_url: {
url: part.image_url.url,
detail: part.image_url.detail || 'auto',
}
};
} else if (part.type === 'tool_code') {
// Content for 'tool_code' should be the code/arguments itself
return { type: 'tool_code', text: part.text };
} else if (part.type === 'tool_output') {
// Content for 'tool_output' is the result from the tool
return { type: 'tool_output', tool_call_id: part.tool_call_id, content: part.content };
}
// If a provider doesn't know how to handle a content type, it should error or log.
console.warn(`BaseProvider: _normalizeContent encountered unknown content part type: ${part.type}`);
return part; // Pass through unknown types
});
}
// If content is neither string nor array, it's likely an error or provider-specific.
// This base normalizer will just pass it. Providers should validate.
return content;
}
}