import { GoogleGenAI } from '@google/genai'; import { GoogleAuth } from 'google-auth-library'; import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client'; import { zodToJsonSchema } from 'zod-to-json-schema'; const VERTEX_PROJECT_ID = process.env.VERTEX_AI_PROJECT_ID || 'gen-lang-client-0980079410'; const VERTEX_LOCATION = process.env.VERTEX_AI_LOCATION || 'global'; const DEFAULT_MODEL = process.env.VERTEX_AI_MODEL || 'gemini-2.0-flash-exp'; // Fast model for collector mode // Helper to set up Google Application Credentials function setupGoogleCredentials() { console.log('[Gemini Client] setupGoogleCredentials called'); console.log('[Gemini Client] FIREBASE_CLIENT_EMAIL:', process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET'); console.log('[Gemini Client] FIREBASE_PRIVATE_KEY:', process.env.FIREBASE_PRIVATE_KEY ? 'SET' : 'NOT SET'); console.log('[Gemini Client] GOOGLE_APPLICATION_CREDENTIALS before:', process.env.GOOGLE_APPLICATION_CREDENTIALS || 'NOT SET'); // Only set up if we have Firebase credentials and Google creds aren't already set if (process.env.FIREBASE_CLIENT_EMAIL && process.env.FIREBASE_PRIVATE_KEY && !process.env.GOOGLE_APPLICATION_CREDENTIALS) { const credentials = { type: 'service_account', project_id: VERTEX_PROJECT_ID, private_key_id: 'firebase-key', private_key: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), client_email: process.env.FIREBASE_CLIENT_EMAIL, client_id: '', auth_uri: 'https://accounts.google.com/o/oauth2/auth', token_uri: 'https://oauth2.googleapis.com/token', auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', client_x509_cert_url: `https://www.googleapis.com/robot/v1/metadata/x509/${encodeURIComponent(process.env.FIREBASE_CLIENT_EMAIL)}`, universe_domain: 'googleapis.com', }; // Write credentials to a temp file that Google Auth Library can read const fs = require('fs'); const os = require('os'); const path = require('path'); const tmpDir = os.tmpdir(); const credPath = path.join(tmpDir, 'google-credentials.json'); try { fs.writeFileSync(credPath, JSON.stringify(credentials)); process.env.GOOGLE_APPLICATION_CREDENTIALS = credPath; console.log('[Gemini Client] ✅ Created credentials file at:', credPath); return true; } catch (error) { console.error('[Gemini Client] ❌ Failed to write credentials file:', error); return false; } } else { console.log('[Gemini Client] Skipping credentials setup - already set or missing Firebase creds'); } return false; } class JsonValidationError extends Error { constructor(message: string, public readonly rawResponse: string) { super(message); } } function extractJsonPayload(raw: string): string { const trimmed = raw.trim(); if (trimmed.startsWith('```')) { const withoutFence = trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, ''); return withoutFence.trim(); } return trimmed; } async function parseResponse( rawResponse: any, schema: StructuredCallArgs['schema'], ): Promise { // Extract text from Google GenAI response // The response structure is: { candidates: [{ content: { parts: [{ text: "..." }] } }] } let text = ''; // Check for truncation const finishReason = rawResponse?.candidates?.[0]?.finishReason; if (finishReason && finishReason !== 'STOP') { console.warn(`[Gemini Client] WARNING: Response may be incomplete. finishReason: ${finishReason}`); } if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) { text = rawResponse.candidates[0].content.parts[0].text; } else if (rawResponse?.text) { text = rawResponse.text; } else if (typeof rawResponse === 'string') { text = rawResponse; } // Check if we got HTML instead of JSON (API error) if (text.trim().startsWith('( args: StructuredCallArgs, ): Promise { if (args.model !== 'gemini') { throw new Error(`GeminiLlmClient only supports model "gemini" (received ${args.model})`); } // Convert Zod schema to JSON Schema for Gemini const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema'); // Extract the actual schema from the definitions (zodToJsonSchema wraps it in $ref) let actualSchema = rawJsonSchema; const rawSchema = rawJsonSchema as any; // Type assertion for $ref access if (rawSchema.$ref && rawSchema.definitions) { const refName = rawSchema.$ref.replace('#/definitions/', ''); actualSchema = rawSchema.definitions[refName]; } // Convert to Google's expected format (UPPERCASE types) const convertToGoogleSchema = (schema: any): any => { if (!schema || typeof schema !== 'object') return schema; const converted: any = {}; if (schema.type) { converted.type = schema.type.toUpperCase(); } if (schema.properties) { converted.properties = {}; for (const [key, value] of Object.entries(schema.properties)) { converted.properties[key] = convertToGoogleSchema(value); } } if (schema.items) { converted.items = convertToGoogleSchema(schema.items); } if (schema.required) { converted.required = schema.required; } if (schema.description) { converted.description = schema.description; } if (schema.enum) { converted.enum = schema.enum; } // Remove additionalProperties since Gemini doesn't use it // (it's a JSON Schema Draft 7 thing) return converted; }; const googleSchema = convertToGoogleSchema(actualSchema); // Debug: Log the schema being sent console.log('[Gemini Client] Sending schema:', JSON.stringify(googleSchema, null, 2)); // Build generation config matching Google's example structure const generationConfig: any = { temperature: args.temperature ?? 1.0, responseMimeType: 'application/json', responseSchema: googleSchema, maxOutputTokens: 32768, // Gemini 3 Pro supports up to 32k output tokens }; // Main request object for REST API (flat structure) const config: any = { contents: [], // Will be populated below generationConfig: generationConfig, }; // Add system instruction if provided if (args.systemPrompt) { // Create a minimal example showing the exact format const exampleJson: any = {}; for (const [key, prop] of Object.entries(googleSchema.properties || {})) { if (key === 'reply') { exampleJson[key] = 'Your response here'; } else { exampleJson[key] = null; // optional field } } config.systemInstruction = { parts: [{ text: `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add thought_process, response, or any other fields. Use only the keys shown above.` }], }; } // Add thinking config if provided (for Gemini 3 Pro Preview) if (args.thinking_config) { config.generationConfig.thinkingConfig = { thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH', includeThoughts: args.thinking_config.include_thoughts || false, }; } // Convert messages to Google GenAI format config.contents = args.messages.map((message) => ({ role: message.role === 'assistant' ? 'model' : 'user', parts: [{ text: message.content }], })); const run = async () => { try { console.log('[Gemini Client] Calling generateContent via REST API...'); // Use direct REST API call instead of SDK (SDK has auth issues) const { GoogleAuth } = require('google-auth-library'); const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'], }); const client = await auth.getClient(); const accessToken = await client.getAccessToken(); const url = `https://aiplatform.googleapis.com/v1/projects/${this.projectId}/locations/${this.location}/publishers/google/models/${this.model}:generateContent`; console.log('[Gemini Client] Making request to:', url); const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken.token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(config), signal: AbortSignal.timeout(180000), // 3 minute timeout }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Vertex AI API error: ${response.status} ${errorText}`); } const result = await response.json(); console.log('[Gemini Client] Got response from Gemini'); return parseResponse(result, args.schema); } catch (error: any) { console.error('[Gemini Client] API call failed:', error.message || error); throw error; } }; try { return await run(); } catch (error) { if (!(error instanceof JsonValidationError)) { throw error; } // Retry with error message config.contents = [ ...config.contents, { role: 'user' as const, parts: [ { text: `Your previous response was not valid JSON. Error: ${error.message}\n` + 'Respond again with ONLY valid JSON that strictly matches the requested schema. Do not include comments or code fences.', }, ], }, ]; return run(); } } }