erosolar-cli
Version:
Unified AI agent framework for the command line - Multi-provider support with schema-driven tools, code intelligence, and transparent reasoning
312 lines • 9.7 kB
JavaScript
import { GoogleGenAI, } from '@google/genai';
// ============================================================================
// Error Recovery Constants
// ============================================================================
const RECOVERABLE_ERROR_PATTERNS = [
'premature close',
'premature end',
'unexpected end',
'aborted',
'fetcherror',
'invalid response body',
'gunzip',
'decompress',
'econnreset',
'econnrefused',
'epipe',
'socket hang up',
'network',
'timeout',
'rate limit',
'429',
'500',
'502',
'503',
'504',
];
function isRecoverableError(error) {
if (!(error instanceof Error))
return false;
const message = error.message.toLowerCase();
const errorName = error.name?.toLowerCase() ?? '';
const allText = `${message} ${errorName}`;
return RECOVERABLE_ERROR_PATTERNS.some(pattern => allText.includes(pattern));
}
export class GoogleGenAIProvider {
id;
model;
client;
temperature;
maxOutputTokens;
maxRetries;
constructor(options) {
this.client = new GoogleGenAI({
apiKey: options.apiKey,
});
this.id = options.providerId ?? 'google';
this.model = options.model;
this.temperature = options.temperature;
this.maxOutputTokens = options.maxOutputTokens;
this.maxRetries = options.maxRetries ?? 3;
}
/**
* Sleep for a given number of milliseconds
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate exponential backoff delay with jitter
*/
getBackoffDelay(attempt, baseDelay = 1000, maxDelay = 30000) {
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
return delay + Math.random() * delay * 0.1;
}
/**
* Execute request with retry logic for transient errors
*/
async executeWithRetry(operation, operationName) {
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await operation();
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (isRecoverableError(error) && attempt < this.maxRetries) {
const delay = this.getBackoffDelay(attempt);
console.warn(`[${this.id}] ${operationName} failed (attempt ${attempt + 1}/${this.maxRetries + 1}): ` +
`${lastError.message}. Retrying in ${Math.round(delay)}ms...`);
await this.sleep(delay);
continue;
}
throw error;
}
}
throw lastError;
}
async generate(messages, tools) {
return this.executeWithRetry(async () => {
const { contents, systemInstruction } = mapConversation(messages);
const config = {};
if (systemInstruction) {
config.systemInstruction = systemInstruction;
}
if (typeof this.temperature === 'number') {
config.temperature = this.temperature;
}
if (typeof this.maxOutputTokens === 'number') {
config.maxOutputTokens = this.maxOutputTokens;
}
const mappedTools = mapTools(tools);
if (mappedTools.length > 0) {
config.tools = mappedTools;
}
const response = await this.client.models.generateContent({
model: this.model,
contents: contents.length ? contents : createEmptyUserContent(),
config: Object.keys(config).length ? config : undefined,
});
const usage = mapUsage(response.usageMetadata);
// Safely extract tool calls with error recovery
let toolCalls = [];
try {
toolCalls = mapFunctionCalls(response.functionCalls ?? []);
}
catch (parseError) {
console.warn(`[${this.id}] Failed to parse function calls, recovering: ` +
`${parseError instanceof Error ? parseError.message : String(parseError)}`);
}
// Extract text content manually to avoid SDK warning when function calls are present
// The SDK's response.text getter logs a warning when there are non-text parts
const content = extractTextFromResponse(response);
if (toolCalls.length > 0) {
return {
type: 'tool_calls',
toolCalls,
content,
usage,
};
}
return {
type: 'message',
content,
usage,
};
}, 'generate');
}
}
function mapConversation(messages) {
const contents = [];
const systemPrompts = [];
for (const message of messages) {
switch (message.role) {
case 'system': {
if (message.content.trim()) {
systemPrompts.push(message.content.trim());
}
break;
}
case 'user': {
contents.push({
role: 'user',
parts: [{ text: message.content }],
});
break;
}
case 'assistant': {
contents.push(mapAssistantMessage(message));
break;
}
case 'tool': {
const content = mapToolMessage(message);
if (content) {
contents.push(content);
}
break;
}
default:
break;
}
}
return {
contents,
systemInstruction: systemPrompts.length ? systemPrompts.join('\n\n') : undefined,
};
}
function mapAssistantMessage(message) {
const parts = [];
const text = message.content.trim();
if (text) {
parts.push({ text });
}
for (const call of message.toolCalls ?? []) {
parts.push({
functionCall: {
id: call.id || undefined,
name: call.name,
args: toRecord(call.arguments),
},
});
}
return {
role: 'model',
parts: parts.length ? parts : [{ text: '' }],
};
}
function mapToolMessage(message) {
if (!message.toolCallId) {
return null;
}
return {
role: 'user',
parts: [
{
functionResponse: {
id: message.toolCallId,
name: message.name,
response: parseToolResponse(message.content),
},
},
],
};
}
function parseToolResponse(content) {
const trimmed = content.trim();
if (!trimmed) {
return { output: '' };
}
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
}
catch {
}
return { output: content };
}
function mapFunctionCalls(calls) {
return calls
.filter((call) => Boolean(call.name))
.map((call) => ({
id: call.id || call.name || 'function_call',
name: call.name ?? 'function_call',
arguments: toRecord(call.args),
}));
}
function createEmptyUserContent() {
return [
{
role: 'user',
parts: [{ text: '' }],
},
];
}
function mapTools(tools) {
if (!tools.length) {
return [];
}
return [
{
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
parametersJsonSchema: tool.parameters ?? { type: 'object', properties: {} },
})),
},
];
}
function mapUsage(metadata) {
if (!metadata) {
return null;
}
return {
inputTokens: metadata.promptTokenCount ?? undefined,
outputTokens: metadata.candidatesTokenCount ?? undefined,
totalTokens: metadata.totalTokenCount ?? undefined,
};
}
function toRecord(value) {
if (isPlainRecord(value)) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
try {
const parsed = JSON.parse(trimmed);
return isPlainRecord(parsed) ? parsed : {};
}
catch {
return {};
}
}
return {};
}
function isPlainRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Extract text content from response without using the SDK's text getter.
* This avoids the SDK warning "there are non-text parts functionCall in the response"
* which is logged when accessing response.text and the response contains function calls.
*/
function extractTextFromResponse(response) {
const candidates = response.candidates;
if (!candidates || candidates.length === 0) {
return '';
}
const content = candidates[0]?.content;
if (!content?.parts) {
return '';
}
// Extract only text parts, ignoring function calls and other part types
const textParts = content.parts
.filter((part) => 'text' in part && typeof part.text === 'string')
.map(part => part.text);
return textParts.join('').trim();
}
//# sourceMappingURL=googleProvider.js.map