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
text/typescript
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);
}
}