UNPKG

ai.libx.js

Version:

Unified API bridge for various AI models (LLMs, image/video generation, TTS, STT) - stateless, edge-compatible

562 lines (482 loc) 15.5 kB
import { BaseAdapter } from './base/BaseAdapter'; import { ChatOptions, ChatResponse, StreamChunk, Message, MessageContent, ContentPart, Tool, ToolChoice, ToolCall, ResponseFormat, ModelInfo } from '../types'; import { streamLines } from '../utils/stream'; import { handleProviderError } from '../utils/errors'; import { contentToString } from '../utils/content-helpers'; import { getModelInfo } from '../models'; interface GooglePart { text?: string; inlineData?: { mimeType: string; data: string; // base64 encoded }; fileData?: { mimeType: string; fileUri: string; }; functionCall?: GoogleFunctionCall; functionResponse?: GoogleFunctionResponse; } interface GoogleContent { role: string; parts: GooglePart[]; } interface GoogleFunctionDeclaration { name: string; description: string; parameters?: object; // JSON Schema } interface GoogleTool { functionDeclarations?: GoogleFunctionDeclaration[]; codeExecution?: {}; // Empty object enables code execution } interface GoogleToolConfig { functionCallingConfig?: { mode?: 'AUTO' | 'ANY' | 'NONE'; allowedFunctionNames?: string[]; }; } interface GoogleFunctionCall { name: string; args: object; // Already parsed object (unlike OpenAI's JSON string) } interface GoogleFunctionResponse { name: string; response: object; } interface GoogleSafetySetting { category: string; threshold: string; } interface GoogleGenerationConfig { temperature?: number; maxOutputTokens?: number; topP?: number; topK?: number; stopSequences?: string[]; candidateCount?: number; presencePenalty?: number; frequencyPenalty?: number; responseLogprobs?: boolean; logprobs?: number; seed?: number; responseMimeType?: string; responseSchema?: object; responseModalities?: string[]; // For multimodal outputs (e.g., ['Text', 'Image']) } interface GoogleRequest { contents: GoogleContent[]; generationConfig?: GoogleGenerationConfig; systemInstruction?: { parts: GooglePart[]; }; tools?: GoogleTool[]; toolConfig?: GoogleToolConfig; safetySettings?: GoogleSafetySetting[]; cachedContent?: string; } /** * Google Gemini API adapter */ export class GoogleAdapter extends BaseAdapter { get name(): string { return 'google'; } async chat(options: ChatOptions): Promise<ChatResponse | AsyncIterable<StreamChunk>> { try { const apiKey = this.getApiKey(options); const baseUrl = this.getBaseUrl('https://generativelanguage.googleapis.com/v1beta'); // Strip provider prefix from model if present (e.g., "google/models/gemini-2.5-pro" -> "models/gemini-2.5-pro") let model = options.model.replace(/^google\//, ''); // Ensure model has "models/" prefix for API if (!model.startsWith('models/')) { model = `models/${model}`; } // Get model info for capabilities const fullModelName = options.model.startsWith('google/') ? options.model : `google/${model}`; const modelInfo = this.getModelCapabilities(fullModelName); // Extract system message if present const systemMessage = options.messages.find((m) => m.role === 'system'); const nonSystemMessages = options.messages.filter((m) => m.role !== 'system'); const request: GoogleRequest = { contents: this.transformMessages(nonSystemMessages), }; // Add system instruction if (systemMessage) { request.systemInstruction = { parts: [{ text: contentToString(systemMessage.content) }], }; } // Add generation config const generationConfig: GoogleGenerationConfig = {}; if (options.temperature !== undefined) generationConfig.temperature = options.temperature; if (options.maxTokens !== undefined) generationConfig.maxOutputTokens = options.maxTokens; if (options.topP !== undefined) generationConfig.topP = options.topP; if (options.topK !== undefined) generationConfig.topK = options.topK; if (options.stop && Array.isArray(options.stop)) { generationConfig.stopSequences = options.stop; } // Advanced parameters if (options.n !== undefined) generationConfig.candidateCount = options.n; if (options.frequencyPenalty !== undefined) generationConfig.frequencyPenalty = options.frequencyPenalty; if (options.presencePenalty !== undefined) generationConfig.presencePenalty = options.presencePenalty; if (options.seed !== undefined) generationConfig.seed = options.seed; if (options.logprobs !== undefined) { generationConfig.responseLogprobs = options.logprobs; if (options.topLogprobs !== undefined) { generationConfig.logprobs = options.topLogprobs; } } // Response format / JSON mode if (options.responseFormat) { const format = this.transformResponseFormat(options.responseFormat); if (format.responseMimeType) { generationConfig.responseMimeType = format.responseMimeType; } if (format.responseSchema) { generationConfig.responseSchema = format.responseSchema; } } // Response modalities for multimodal output (e.g., text + image generation) if (modelInfo?.responseModalities && modelInfo.responseModalities.length > 0) { generationConfig.responseModalities = modelInfo.responseModalities; } if (Object.keys(generationConfig).length > 0) { request.generationConfig = generationConfig; } // Add tools if provided if (options.tools && options.tools.length > 0) { request.tools = this.transformTools(options.tools); if (options.toolChoice) { request.toolConfig = this.transformToolChoice(options.toolChoice); } } // Merge provider-specific options if (options.providerOptions) { Object.assign(request, options.providerOptions); } const endpoint = options.stream ? 'streamGenerateContent' : 'generateContent'; const url = `${baseUrl}/${model}:${endpoint}?key=${apiKey}`; const response = await this.fetchWithErrorHandling( url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }, this.name ); if (options.stream) { return this.handleStreamResponse(response, model); } return this.handleNonStreamResponse(await response.json(), model); } catch (error) { handleProviderError(error, this.name); } } private transformMessages(messages: Message[]): GoogleContent[] { return messages.map((msg) => ({ role: msg.role === 'assistant' ? 'model' : msg.role === 'tool' ? 'function' : 'user', parts: this.transformContentParts(msg.content, msg), })); } private transformContentParts(content: MessageContent, msg: Message): GooglePart[] { // Handle tool call responses (tool role messages) if (msg.role === 'tool' && msg.tool_call_id) { return [{ functionResponse: { name: msg.name || 'unknown', response: typeof content === 'string' ? { result: content } : { result: JSON.stringify(content) }, }, }]; } // Handle assistant messages with tool calls if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { const parts: GooglePart[] = []; // Add text content if present if (content) { parts.push(...this.transformRegularContent(content)); } // Add function calls msg.tool_calls.forEach(toolCall => { parts.push({ functionCall: { name: toolCall.function.name, args: JSON.parse(toolCall.function.arguments), }, }); }); return parts; } // Regular content (text or multimodal) return this.transformRegularContent(content); } private transformRegularContent(content: MessageContent): GooglePart[] { // Simple string content if (typeof content === 'string') { return [{ text: content }]; } // Array of content parts (multimodal) return content.map(part => { if (part.type === 'text') { return { text: part.text || '' }; } if (part.type === 'image_url' && part.image_url) { return this.transformImagePart(part.image_url); } throw new Error(`Unsupported content part type: ${part.type}`); }); } private transformImagePart(imageUrl: { url: string; detail?: string; }): GooglePart { const url = imageUrl.url; // Data URL (base64) - e.g., "data:image/jpeg;base64,..." if (url.startsWith('data:')) { const match = url.match(/^data:([^;]+);base64,(.+)$/); if (!match) { throw new Error('Invalid data URL format. Expected: data:mime/type;base64,<data>'); } return { inlineData: { mimeType: match[1], data: match[2], }, }; } // Google Files API URL if (url.startsWith('https://generativelanguage.googleapis.com/v1beta/files/')) { const mimeType = this.getMimeTypeFromUrl(url); return { fileData: { mimeType, fileUri: url, }, }; } // External URL - not directly supported by Google throw new Error( 'External image URLs not supported by Google Gemini. ' + 'Use data URLs (base64) or upload to Files API first. ' + 'See: https://ai.google.dev/gemini-api/docs/prompting_with_media' ); } private getMimeTypeFromUrl(url: string): string { const ext = url.split('.').pop()?.toLowerCase(); const mimeTypes: Record<string, string> = { // Images 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic', 'heif': 'image/heif', // Audio 'mp3': 'audio/mp3', 'wav': 'audio/wav', 'aiff': 'audio/aiff', 'aac': 'audio/aac', 'ogg': 'audio/ogg', 'flac': 'audio/flac', // Video 'mp4': 'video/mp4', 'mpeg': 'video/mpeg', 'mov': 'video/mov', 'avi': 'video/avi', 'flv': 'video/x-flv', 'mpg': 'video/mpg', 'webm': 'video/webm', 'wmv': 'video/wmv', '3gpp': 'video/3gpp', // Documents 'pdf': 'application/pdf', 'txt': 'text/plain', 'html': 'text/html', 'css': 'text/css', 'md': 'text/md', 'csv': 'text/csv', 'xml': 'text/xml', 'rtf': 'text/rtf', }; return mimeTypes[ext || ''] || 'application/octet-stream'; } private transformTools(tools: Tool[]): GoogleTool[] { const functionDeclarations = tools.map(tool => ({ name: tool.function.name, description: tool.function.description || '', parameters: tool.function.parameters, })); return [{ functionDeclarations }]; } private transformToolChoice(toolChoice: ToolChoice): GoogleToolConfig { if (typeof toolChoice === 'string') { const modeMap: Record<string, 'AUTO' | 'ANY' | 'NONE'> = { 'auto': 'AUTO', 'required': 'ANY', 'none': 'NONE', }; return { functionCallingConfig: { mode: modeMap[toolChoice] || 'AUTO', }, }; } // Specific function requested if ('function' in toolChoice) { return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name], }, }; } return { functionCallingConfig: { mode: 'AUTO' } }; } private transformResponseFormat(format: ResponseFormat): { responseMimeType?: string; responseSchema?: object; } { if (format.type === 'json_object') { return { responseMimeType: 'application/json' }; } if (format.type === 'json_schema' && format.json_schema) { return { responseMimeType: 'application/json', responseSchema: format.json_schema.schema, }; } return {}; } private handleNonStreamResponse(data: any, model: string): ChatResponse { const candidate = data.candidates?.[0]; if (!candidate) { throw new Error('No candidates in response'); } const content = this.extractContentText(candidate.content); const toolCalls = this.extractToolCalls(candidate.content); // Check for safety blocks if (candidate.safetyRatings) { const blocked = candidate.safetyRatings.some((r: any) => r.blocked); if (blocked) { console.warn('Content blocked by safety filters:', candidate.safetyRatings); } } return { content, finishReason: candidate.finishReason, toolCalls, usage: data.usageMetadata ? { promptTokens: data.usageMetadata.promptTokenCount || 0, completionTokens: data.usageMetadata.candidatesTokenCount || 0, totalTokens: data.usageMetadata.totalTokenCount || 0, } : undefined, model, raw: data, }; } private extractContentText(content: GoogleContent): string { if (!content?.parts) return ''; return content.parts .filter(part => part.text) .map(part => part.text) .join(''); } private extractToolCalls(content: GoogleContent): ToolCall[] | undefined { if (!content?.parts) return undefined; const toolCalls: ToolCall[] = []; content.parts.forEach((part, index) => { if (part.functionCall) { toolCalls.push({ id: `call_${Date.now()}_${index}`, // Google doesn't provide IDs, so we generate them type: 'function', function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args), // Convert object to JSON string for unified interface }, }); } }); return toolCalls.length > 0 ? toolCalls : undefined; } private async *handleStreamResponse(response: Response, model: string): AsyncIterable<StreamChunk> { if (!response.body) { throw new Error('No response body for streaming'); } const accumulatedToolCalls: GoogleFunctionCall[] = []; let buffer = ''; // Buffer for incomplete JSON chunks for await (const line of streamLines(response.body)) { if (!line.trim() || line.trim() === '[' || line.trim() === ']') continue; // Remove trailing comma if present const cleanLine = line.trim().replace(/,$/, ''); try { // Try to parse current line const chunk = JSON.parse(buffer + cleanLine); buffer = ''; // Clear buffer on success const candidate = chunk.candidates?.[0]; if (!candidate) continue; const content = this.extractContentText(candidate.content); const finishReason = candidate.finishReason; // Accumulate tool calls if (candidate.content?.parts) { candidate.content.parts.forEach((part: GooglePart) => { if (part.functionCall) { accumulatedToolCalls.push(part.functionCall); } }); } if (content || finishReason) { const streamChunk: StreamChunk = { content, finishReason, }; // Include tool calls on finish if (finishReason && accumulatedToolCalls.length > 0) { streamChunk.toolCalls = accumulatedToolCalls.map((fc, i) => ({ id: `call_${Date.now()}_${i}`, type: 'function' as const, function: { name: fc.name, arguments: JSON.stringify(fc.args), }, })); } yield streamChunk; } } catch (e) { // Accumulate incomplete JSON chunks buffer += cleanLine; // If buffer gets too large, it's likely an error, not incomplete JSON if (buffer.length > 100000) { console.warn('Google streaming: Buffer exceeded limit, clearing'); buffer = ''; } continue; } } // Try to parse any remaining buffer content if (buffer.trim()) { try { const chunk = JSON.parse(buffer); const candidate = chunk.candidates?.[0]; if (candidate) { const content = this.extractContentText(candidate.content); const finishReason = candidate.finishReason; if (content || finishReason) { yield { content, finishReason }; } } } catch (e) { // Final buffer content was invalid, skip } } } /** * Get model capabilities from models registry */ private getModelCapabilities(fullModelName: string): ModelInfo | undefined { return getModelInfo(fullModelName); } }