feat: migrate Gemini from Vertex AI to Google AI Studio API key

- gemini-client.ts: replaces Vertex AI REST + service account auth with
  direct generativelanguage.googleapis.com calls using GOOGLE_API_KEY.
  Removes all Firebase credential setup code.
- summarize/route.ts: same migration, simplified to a single fetch call.
- No longer depends on gen-lang-client-0980079410 GCP project for AI calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-19 14:35:44 -08:00
parent 106d9c5ff1
commit e7f33211b9
2 changed files with 102 additions and 267 deletions

View File

@@ -1,15 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GoogleGenAI } from "@google/genai";
const VERTEX_AI_MODEL = process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview'; const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const VERTEX_PROJECT_ID = process.env.VERTEX_AI_PROJECT_ID || 'gen-lang-client-0980079410'; const API_KEY = process.env.GOOGLE_API_KEY || '';
const VERTEX_LOCATION = process.env.VERTEX_AI_LOCATION || 'global'; const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
const genAI = new GoogleGenAI({
project: VERTEX_PROJECT_ID,
location: VERTEX_LOCATION,
vertexai: true,
});
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -19,8 +12,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Content is required" }, { status: 400 }); return NextResponse.json({ error: "Content is required" }, { status: 400 });
} }
// Truncate content if it's too long (Gemini has token limits) const maxContentLength = 30000;
const maxContentLength = 30000; // ~30k characters
const truncatedContent = content.length > maxContentLength const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..." ? content.substring(0, maxContentLength) + "..."
: content; : content;
@@ -32,27 +24,27 @@ ${truncatedContent}
Summary:`; Summary:`;
const result = await genAI.models.generateContent({ const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
model: VERTEX_AI_MODEL, method: 'POST',
contents: [{ headers: { 'Content-Type': 'application/json' },
role: 'user', body: JSON.stringify({
parts: [{ text: prompt }], contents: [{ role: 'user', parts: [{ text: prompt }] }],
}], generationConfig: { temperature: 0.3 },
config: { }),
temperature: 0.3, // Lower temperature for consistent summaries
},
}); });
if (!response.ok) {
throw new Error(`Gemini API error (${response.status}): ${await response.text()}`);
}
const result = await response.json();
const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable'; const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable';
return NextResponse.json({ summary }); return NextResponse.json({ summary });
} catch (error) { } catch (error) {
console.error("Error generating summary:", error); console.error("Error generating summary:", error);
return NextResponse.json( return NextResponse.json(
{ { error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
error: "Failed to generate summary",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -1,57 +1,10 @@
import { GoogleGenAI } from '@google/genai';
import { GoogleAuth } from 'google-auth-library';
import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client'; import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
const VERTEX_PROJECT_ID = process.env.VERTEX_AI_PROJECT_ID || 'gen-lang-client-0980079410'; const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const VERTEX_LOCATION = process.env.VERTEX_AI_LOCATION || 'global'; const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || '';
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 const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
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 { class JsonValidationError extends Error {
constructor(message: string, public readonly rawResponse: string) { constructor(message: string, public readonly rawResponse: string) {
@@ -62,8 +15,7 @@ class JsonValidationError extends Error {
function extractJsonPayload(raw: string): string { function extractJsonPayload(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (trimmed.startsWith('```')) { if (trimmed.startsWith('```')) {
const withoutFence = trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, ''); return trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
return withoutFence.trim();
} }
return trimmed; return trimmed;
} }
@@ -72,14 +24,11 @@ async function parseResponse<TOutput>(
rawResponse: any, rawResponse: any,
schema: StructuredCallArgs<TOutput>['schema'], schema: StructuredCallArgs<TOutput>['schema'],
): Promise<TOutput> { ): Promise<TOutput> {
// Extract text from Google GenAI response
// The response structure is: { candidates: [{ content: { parts: [{ text: "..." }] } }] }
let text = ''; let text = '';
// Check for truncation
const finishReason = rawResponse?.candidates?.[0]?.finishReason; const finishReason = rawResponse?.candidates?.[0]?.finishReason;
if (finishReason && finishReason !== 'STOP') { if (finishReason && finishReason !== 'STOP') {
console.warn(`[Gemini Client] WARNING: Response may be incomplete. finishReason: ${finishReason}`); console.warn(`[Gemini] WARNING: Response may be incomplete. finishReason: ${finishReason}`);
} }
if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) { if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) {
@@ -90,21 +39,17 @@ async function parseResponse<TOutput>(
text = rawResponse; text = rawResponse;
} }
// Check if we got HTML instead of JSON (API error)
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) { if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
console.error('[Gemini Client] Received HTML instead of JSON. This usually means an API authentication or permission error.'); console.error('[Gemini] Received HTML — likely an API auth error');
console.error('[Gemini Client] Response preview:', text.substring(0, 500)); throw new Error('Gemini API returned HTML. Check GOOGLE_API_KEY.');
throw new Error('Gemini API returned HTML instead of JSON. Check API permissions and authentication. See server logs for details.');
} }
if (!text) { if (!text) {
console.error('[Gemini Client] Empty response from API'); console.error('[Gemini] Empty response:', JSON.stringify(rawResponse)?.slice(0, 300));
console.error('[Gemini Client] Raw response:', JSON.stringify(rawResponse, null, 2)?.substring(0, 500));
throw new Error('Empty response from Gemini API'); throw new Error('Empty response from Gemini API');
} }
// Debug: Log what we received console.log('[Gemini] Response preview:', text.slice(0, 200));
console.log('[Gemini Client] Received text:', text.substring(0, 300));
const cleaned = extractJsonPayload(text); const cleaned = extractJsonPayload(text);
@@ -112,22 +57,15 @@ async function parseResponse<TOutput>(
try { try {
parsed = JSON.parse(cleaned); parsed = JSON.parse(cleaned);
} catch (error) { } catch (error) {
console.error('[Gemini Client] Failed to parse response as JSON');
console.error('[Gemini Client] Raw text:', text.substring(0, 500));
throw new JsonValidationError( throw new JsonValidationError(
`Failed to parse JSON response: ${(error as Error).message}`, `Failed to parse JSON: ${(error as Error).message}`,
text, text,
); );
} }
// Debug: Log what we parsed
console.log('[Gemini Client] Parsed JSON:', JSON.stringify(parsed, null, 2).substring(0, 300));
const validation = schema.safeParse(parsed); const validation = schema.safeParse(parsed);
if (!validation.success) { if (!validation.success) {
console.error('[Gemini Client] Schema validation failed!'); console.error('[Gemini] Schema validation failed:', validation.error.errors);
console.error('[Gemini Client] Received JSON had these keys:', Object.keys(parsed as any));
console.error('[Gemini Client] Validation errors:', validation.error.errors);
throw new JsonValidationError(validation.error.message, text); throw new JsonValidationError(validation.error.message, text);
} }
@@ -135,211 +73,116 @@ async function parseResponse<TOutput>(
} }
export class GeminiLlmClient implements LlmClient { export class GeminiLlmClient implements LlmClient {
private readonly genAI: GoogleGenAI;
private readonly model: string; private readonly model: string;
private readonly location: string;
private readonly projectId: string;
constructor() { constructor() {
// Google GenAI SDK with Vertex AI support
this.projectId = VERTEX_PROJECT_ID;
this.location = VERTEX_LOCATION;
this.model = DEFAULT_MODEL; this.model = DEFAULT_MODEL;
if (!GOOGLE_API_KEY) {
// Set up Google Application Credentials BEFORE initializing the SDK console.warn('[Gemini] WARNING: GOOGLE_API_KEY is not set');
setupGoogleCredentials(); }
console.log(`[Gemini] Initialized — model: ${this.model}`);
// Debug: Check environment variables
console.log('[Gemini Client] Environment check:');
console.log(' VERTEX_AI_PROJECT_ID:', process.env.VERTEX_AI_PROJECT_ID);
console.log(' VERTEX_AI_LOCATION:', process.env.VERTEX_AI_LOCATION);
console.log(' VERTEX_AI_MODEL:', process.env.VERTEX_AI_MODEL);
console.log(' GOOGLE_APPLICATION_CREDENTIALS:', process.env.GOOGLE_APPLICATION_CREDENTIALS ? 'SET' : 'NOT SET');
console.log(' FIREBASE_CLIENT_EMAIL:', process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET');
// Initialize with Vertex AI configuration
// The SDK will automatically use GOOGLE_APPLICATION_CREDENTIALS if set
this.genAI = new GoogleGenAI({
project: this.projectId,
location: this.location,
vertexai: true, // Enable Vertex AI mode
});
console.log(`[Gemini Client] Initialized with model: ${this.model}, location: ${this.location}`);
} }
async structuredCall<TOutput>( async structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput> {
args: StructuredCallArgs<TOutput>,
): Promise<TOutput> {
if (args.model !== 'gemini') { if (args.model !== 'gemini') {
throw new Error(`GeminiLlmClient only supports model "gemini" (received ${args.model})`); throw new Error(`GeminiLlmClient only supports model "gemini" (got ${args.model})`);
} }
// Convert Zod schema to JSON Schema for Gemini // Convert Zod schema → Google schema format
const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema'); const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema') as any;
let actualSchema: any = rawJsonSchema;
// Extract the actual schema from the definitions (zodToJsonSchema wraps it in $ref) if (rawJsonSchema.$ref && rawJsonSchema.definitions) {
let actualSchema = rawJsonSchema; const refName = rawJsonSchema.$ref.replace('#/definitions/', '');
const rawSchema = rawJsonSchema as any; // Type assertion for $ref access actualSchema = rawJsonSchema.definitions[refName];
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 => { const convertToGoogleSchema = (schema: any): any => {
if (!schema || typeof schema !== 'object') return schema; if (!schema || typeof schema !== 'object') return schema;
const out: any = {};
const converted: any = {}; if (schema.type) out.type = schema.type.toUpperCase();
if (schema.type) {
converted.type = schema.type.toUpperCase();
}
if (schema.properties) { if (schema.properties) {
converted.properties = {}; out.properties = {};
for (const [key, value] of Object.entries(schema.properties)) { for (const [k, v] of Object.entries(schema.properties)) {
converted.properties[key] = convertToGoogleSchema(value); out.properties[k] = convertToGoogleSchema(v);
} }
} }
if (schema.items) out.items = convertToGoogleSchema(schema.items);
if (schema.items) { if (schema.required) out.required = schema.required;
converted.items = convertToGoogleSchema(schema.items); if (schema.description) out.description = schema.description;
} if (schema.enum) out.enum = schema.enum;
return out;
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); const googleSchema = convertToGoogleSchema(actualSchema);
// Debug: Log the schema being sent // Build request body
console.log('[Gemini Client] Sending schema:', JSON.stringify(googleSchema, null, 2)); const body: any = {
contents: args.messages.map((m) => ({
// Build generation config matching Google's example structure role: m.role === 'assistant' ? 'model' : 'user',
const generationConfig: any = { parts: [{ text: m.content }],
temperature: args.temperature ?? 1.0, })),
responseMimeType: 'application/json', generationConfig: {
responseSchema: googleSchema, temperature: args.temperature ?? 1.0,
maxOutputTokens: 32768, // Gemini 3 Pro supports up to 32k output tokens responseMimeType: 'application/json',
responseSchema: googleSchema,
maxOutputTokens: 32768,
},
}; };
// 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) { if (args.systemPrompt) {
// Create a minimal example showing the exact format
const exampleJson: any = {}; const exampleJson: any = {};
for (const [key, prop] of Object.entries(googleSchema.properties || {})) { for (const key of Object.keys(googleSchema.properties || {})) {
if (key === 'reply') { exampleJson[key] = key === 'reply' ? 'Your response here' : null;
exampleJson[key] = 'Your response here';
} else {
exampleJson[key] = null; // optional field
}
} }
body.systemInstruction = {
config.systemInstruction = {
parts: [{ 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.` text: `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`,
}], }],
}; };
} }
// Add thinking config if provided (for Gemini 3 Pro Preview)
if (args.thinking_config) { if (args.thinking_config) {
config.generationConfig.thinkingConfig = { body.generationConfig.thinkingConfig = {
thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH', thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH',
includeThoughts: args.thinking_config.include_thoughts || false, includeThoughts: args.thinking_config.include_thoughts || false,
}; };
} }
// Convert messages to Google GenAI format const url = `${GEMINI_BASE_URL}/${this.model}:generateContent?key=${GOOGLE_API_KEY}`;
config.contents = args.messages.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
const run = async () => { const run = async () => {
try { console.log(`[Gemini] POST ${GEMINI_BASE_URL}/${this.model}:generateContent`);
console.log('[Gemini Client] Calling generateContent via REST API...'); const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(180_000),
});
// Use direct REST API call instead of SDK (SDK has auth issues) if (!response.ok) {
const { GoogleAuth } = require('google-auth-library'); const errorText = await response.text();
const auth = new GoogleAuth({ throw new Error(`Gemini API error (${response.status}): ${errorText}`);
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;
} }
const result = await response.json();
return parseResponse(result, args.schema);
}; };
try { try {
return await run(); return await run();
} catch (error) { } catch (error) {
if (!(error instanceof JsonValidationError)) { if (!(error instanceof JsonValidationError)) throw error;
throw error;
}
// Retry with error message // Retry once on JSON parse failure
config.contents = [ body.contents = [
...config.contents, ...body.contents,
{ {
role: 'user' as const, role: 'user',
parts: [ parts: [{
{ text: `Your previous response was not valid JSON. Error: ${error.message}\nRespond again with ONLY valid JSON matching the schema. No code fences or comments.`,
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(); return run();
} }
} }