65 lines
2.7 KiB
TypeScript
65 lines
2.7 KiB
TypeScript
/**
|
|
* 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<GeminiResult> {
|
|
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<string, unknown> = {
|
|
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() };
|
|
}
|