UNPKG

llmplug

Version:

A library to easily integrate various LLM models and vendors into applications, with advanced features.

328 lines (296 loc) 15.9 kB
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; } }