← back to Freddy
lib/gemini.ts
110 lines
/**
* Gemini 2.0 Flash wrapper — single source of truth for Freddy's 4 AI call
* sites: causes/discover, matches/generate, contacts/discover, contacts/generate-letter.
*
* 2026-05-30 (tick 11 from TODO.md): ported from Patty's lib/gemini.ts after the
* soak window. Replaces the (key + URL + AbortSignal + fetch + parse + fence-strip)
* pattern that was duplicated across the 4 AI routes. Forces env discipline — if
* GEMINI_API_KEY isn't set, every callsite gets a 503 reason='no_key' instead of
* silently calling Google with key=''.
*
* Returns a Result-shaped object instead of throwing — callers map status codes
* (504 abort / 502 upstream / 500 parse). Generic <T> is the typed shape for
* parsed JSON when parseJson=true; raw string is also returned.
*/
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const MODEL = 'gemini-2.0-flash';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
export type GeminiOk<T> = {
ok: true;
data: T;
raw: string;
inputTokens: number;
outputTokens: number;
};
export type GeminiErr = {
ok: false;
reason: 'no_key' | 'timeout' | 'upstream' | 'parse';
status: number;
detail?: string;
};
export type GeminiResult<T> = GeminiOk<T> | GeminiErr;
export async function callGemini<T = unknown>(opts: {
prompt: string;
maxTokens?: number;
temperature?: number;
timeoutMs?: number;
parseJson?: boolean;
}): Promise<GeminiResult<T>> {
if (!GEMINI_KEY) {
return { ok: false, reason: 'no_key', status: 503, detail: 'GEMINI_API_KEY env unset' };
}
const {
prompt,
maxTokens = 4096,
temperature = 0.7,
timeoutMs = 25_000,
parseJson = true,
} = opts;
let res: Response;
try {
res = await fetch(`${GEMINI_URL}?key=${GEMINI_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature, maxOutputTokens: maxTokens },
}),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
return { ok: false, reason: 'timeout', status: 504, detail: (e as Error).message };
}
if (!res.ok) {
const detail = await res.text().catch(() => '');
console.error('[lib/gemini] upstream error:', res.status, detail.slice(0, 500));
return { ok: false, reason: 'upstream', status: 502, detail: `gemini ${res.status}` };
}
const data = await res.json();
const raw: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
const usage = data.usageMetadata ?? {};
const inputTokens: number = usage.promptTokenCount ?? 0;
const outputTokens: number = usage.candidatesTokenCount ?? 0;
if (!parseJson) {
return {
ok: true,
data: raw as unknown as T,
raw,
inputTokens,
outputTokens,
};
}
// Strip ```json fences then JSON.parse. Caller is responsible for
// structural validation of the parsed payload.
const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
try {
const parsed = JSON.parse(cleaned) as T;
return {
ok: true,
data: parsed,
raw,
inputTokens,
outputTokens,
};
} catch (e) {
console.error('[lib/gemini] parse error:', cleaned.slice(0, 500));
return { ok: false, reason: 'parse', status: 500, detail: (e as Error).message };
}
}