← back to Patty

lib/gemini.ts

112 lines

/**
 * Gemini 2.0 Flash wrapper — single source of truth for Patty's 7 AI call
 * sites: petitions/generate, campaigns/generate, trending/discover,
 * orbit/{rss,suggest-alliance,batch-link,generate}.
 *
 * 2026-05-05 (tick 27): ported from Grant's lib/gemini.ts after the
 * architect-reviewer flagged 7x duplication of (key + URL + AbortSignal +
 * fetch + parse + fence-strip) across the same routes that just got their
 * hardcoded Gemini key scrubbed. This wrapper 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 };
  }
}