/** * Server-only Gemini client. Powers the AI assistant + translation features. * * SECURITY: GEMINI_API_KEY is read from the server environment and NEVER exposed * to the client bundle (no NEXT_PUBLIC_ prefix). All calls go through BFF routes * so the key stays on the server. This module must only be imported by route * handlers / server code. */ const ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models"; export type GeminiResult = | { ok: true; text: string } | { ok: false; status: number; error: string; retryable: boolean }; /** * Single-shot text generation. Returns a discriminated result so callers can * surface a graceful message (e.g. on 429 quota-exhausted) instead of throwing. */ export async function geminiGenerate(prompt: string, opts?: { system?: string; temperature?: number }): Promise { const key = process.env.GEMINI_API_KEY; const model = process.env.GEMINI_MODEL || "gemini-2.0-flash"; if (!key) { return { ok: false, status: 0, error: "GEMINI_API_KEY is not configured on the server.", retryable: false }; } const body: Record = { contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { temperature: opts?.temperature ?? 0.4 }, }; if (opts?.system) { body.systemInstruction = { parts: [{ text: opts.system }] }; } let res: Response; try { res = await fetch(`${ENDPOINT}/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); } catch (e) { return { ok: false, status: 0, error: `Could not reach Gemini: ${(e as Error).message}`, retryable: true }; } if (!res.ok) { const detail = await res.text().catch(() => ""); const retryable = res.status === 429 || res.status >= 500; let msg = `Gemini error ${res.status}`; if (res.status === 429) msg = "The AI is rate-limited right now (quota exhausted). Please try again shortly."; else if (res.status === 403) msg = "The AI request was rejected (check the API key / permissions)."; return { ok: false, status: res.status, error: msg, retryable }; } const json = await res.json().catch(() => null); const text: string | undefined = json?.candidates?.[0]?.content?.parts?.map((p: any) => p?.text).filter(Boolean).join("\n"); if (!text) { // Blocked (safety) or empty candidate. const reason = json?.candidates?.[0]?.finishReason || json?.promptFeedback?.blockReason; return { ok: false, status: 200, error: reason ? `The AI returned no text (${reason}).` : "The AI returned an empty response.", retryable: false }; } return { ok: true, text: text.trim() }; }