first commit
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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() };
|
||||
}
|
||||
Reference in New Issue
Block a user