Docs/Skills and code/src/

src/gemini.js

Code362 lines13.6 KBsrc/

Mentioned on: page 12

import { TextDecoder } from "util";
import { fileURLToPath } from "node:url";

// ============================================================
// GOOGLE GEMINI API ADAPTER (Gemma 4 models)
//
// Transport only: no dependency on src/llm.js, so llm.js can import this file.
// Needs GEMINI_API_KEY (or GOOGLE_API_KEY), from the environment or the project .env.
// Optional: GEMINI_MODEL, GEMINI_BASE_URL.
//
// RATE LIMITS
// Limits are per project (not per API key) and have three dimensions: requests per
// minute (RPM), input tokens per minute (TPM) and requests per day (RPD). Per-minute
// limits clear after about a minute; the daily limit resets at midnight Pacific time.
// Google does not list Gemma's numbers in its docs; see https://aistudio.google.com/rate-limit
// A 429 is classified so callers can wait for a minute limit but give up on a daily one.
// ============================================================

try {
  process.loadEnvFile(fileURLToPath(new URL("../.env", import.meta.url)));
} catch {
  // No .env file; rely on the real environment. Variables already set are never overridden.
}

// The 26b is the default because the 31b waited 18-66 s for its first byte (even on tiny prompts)
// and often answered 500/503 "high demand" on the free tier; the 26b answers in about a second.
export const DEFAULT_GEMINI_MODEL = "gemma-4-26b-a4b-it";
export const GEMMA_MODELS = ["gemma-4-31b-it", "gemma-4-26b-a4b-it"];
export const LIMITS_URL = "https://aistudio.google.com/rate-limit";

const REQUEST_TIMEOUT_MS = 120000;
const RETRYABLE_KINDS = ["requests-per-minute-limit", "tokens-per-minute-limit", "rate-limit-unknown", "overloaded", "server-error"];

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export function getGeminiConfig() {
  return {
    baseUrl: (process.env.GEMINI_BASE_URL || "https://generativelanguage.googleapis.com/v1beta").replace(/\/$/, ""),
    apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "",
    model: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL
  };
}

// ------------------------------------------------------------
// Errors and rate limits
// ------------------------------------------------------------

export class GeminiError extends Error {
  constructor(message, { status = null, kind = "error", retryAfterSeconds = null } = {}) {
    super(message);
    this.name = "GeminiError";
    this.status = status;
    this.kind = kind;
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

// Google's docs do not spell out the 429 body, so this reads every signal it can find
// (QuotaFailure ids, the message text, RetryInfo) and falls back to "rate-limit-unknown".
export function classifyGeminiError(status, bodyText) {
  let error = null;
  try {
    error = JSON.parse(bodyText)?.error ?? null;
  } catch {
    // Not JSON; the raw text is used below.
  }

  const message = String(error?.message ?? bodyText).slice(0, 600);
  const details = Array.isArray(error?.details) ? error.details : [];
  const typed = (suffix) => details.filter((d) => String(d?.["@type"] ?? "").endsWith(suffix));

  const quotaText = typed("QuotaFailure")
    .flatMap((d) => d.violations ?? [])
    .map((v) => `${v.quotaId ?? ""} ${v.quotaMetric ?? ""}`)
    .join(" ");
  const haystack = `${quotaText} ${message}`;

  let kind = "error";
  if (status === 429 || error?.status === "RESOURCE_EXHAUSTED") {
    if (/per ?day/i.test(haystack)) {
      kind = "daily-limit";
    } else if (/per ?minute/i.test(haystack)) {
      kind = /token/i.test(haystack) ? "tokens-per-minute-limit" : "requests-per-minute-limit";
    } else {
      kind = "rate-limit-unknown";
    }
  } else if (status === 503 || error?.status === "UNAVAILABLE") {
    kind = "overloaded";
  } else if (status === 500 || status === 502 || status === 504 || error?.status === "INTERNAL") {
    kind = "server-error";
  } else if (status === 401 || status === 403 || /api key not valid/i.test(message)) {
    kind = "auth";
  }

  const retryDelay = typed("RetryInfo")[0]?.retryDelay;
  const retryAfterSeconds =
    Number.parseFloat(retryDelay) || Number.parseFloat(message.match(/retry in ([\d.]+)\s*s/i)?.[1]) || null;

  return { kind, message, retryAfterSeconds };
}

export function secondsUntilPacificMidnight(now = new Date()) {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone: "America/Los_Angeles", hourCycle: "h23", hour: "numeric", minute: "numeric", second: "numeric"
  }).formatToParts(now);
  const part = (type) => Number(parts.find((p) => p.type === type)?.value ?? 0);
  return 86400 - (part("hour") * 3600 + part("minute") * 60 + part("second"));
}

function formatDuration(totalSeconds) {
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.round((totalSeconds % 3600) / 60);
  return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
}

export function explainGeminiError(error) {
  if (!(error instanceof GeminiError)) {
    return error.message;
  }

  switch (error.kind) {
    case "daily-limit": {
      const seconds = secondsUntilPacificMidnight();
      const local = new Date(Date.now() + seconds * 1000).toLocaleString();
      return `DAILY limit reached. Retrying will not help; it resets at midnight Pacific, in about ${formatDuration(seconds)} (${local} local).`;
    }
    case "requests-per-minute-limit":
      return "Per-minute REQUEST limit reached. It clears after about a minute; space calls out.";
    case "tokens-per-minute-limit":
      return "Per-minute INPUT-TOKEN limit reached. Send smaller batches or slow down.";
    case "rate-limit-unknown":
      return `Rate limited, but the response did not say which limit. Check ${LIMITS_URL}. Message: ${error.message}`;
    case "overloaded":
      return `Model temporarily overloaded (503). Try again shortly. Message: ${error.message}`;
    default:
      return error.message;
  }
}

// ------------------------------------------------------------
// API calls
// ------------------------------------------------------------

async function request(path, { method = "GET", body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
  const { baseUrl, apiKey } = getGeminiConfig();

  if (!apiKey) {
    throw new GeminiError("GEMINI_API_KEY is not set. Put it in the environment or in a .env file.", { kind: "no-key" });
  }

  let res;
  try {
    // The key goes in a header, not the URL, so it cannot end up in logs or error text.
    res = await fetch(`${baseUrl}/${path}`, {
      method,
      headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(timeoutMs)
    });
  } catch (error) {
    const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
    throw new GeminiError(
      `Gemini request ${timedOut ? `timed out after ${Math.round(timeoutMs / 1000)}s` : "could not be sent"} (${path.split("?")[0]}): ${error.message}`,
      { kind: timedOut ? "timeout" : "network" }
    );
  }

  if (!res.ok) {
    const info = classifyGeminiError(res.status, await res.text());
    throw new GeminiError(`Gemini request failed (${path.split("?")[0]}): ${res.status} ${info.message}`, {
      status: res.status, kind: info.kind, retryAfterSeconds: info.retryAfterSeconds
    });
  }

  return res;
}

// Only the Gemma models that can generate text. Also confirms the key and model ids work.
export async function listGemmaModels() {
  const models = [];
  let pageToken = "";

  do {
    const suffix = pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : "";
    const data = await (await request(`models?pageSize=1000${suffix}`)).json();
    models.push(...(data.models ?? []));
    pageToken = data.nextPageToken ?? "";
  } while (pageToken);

  return models
    .filter((m) => /gemma/i.test(m.name) && (m.supportedGenerationMethods ?? ["generateContent"]).includes("generateContent"))
    .map((m) => ({
      id: m.name.replace(/^models\//, ""),
      inputTokenLimit: m.inputTokenLimit ?? null,
      outputTokenLimit: m.outputTokenLimit ?? null
    }))
    .sort((a, b) => a.id.localeCompare(b.id));
}

// Thought parts (part.thought === true) are the model's reasoning, not the answer.
const answerText = (candidate) =>
  (candidate?.content?.parts ?? []).filter((part) => !part.thought).map((part) => part.text ?? "").join("");

// system is sent as systemInstruction. Pass everything in `user` (and leave system empty) to
// send one combined prompt string, the way src/llm.js does for Ollama.
export async function callGemini({
  system = "",
  user = "",
  model = getGeminiConfig().model,
  stream = true,
  maxOutputTokens = 1200,
  temperature = 0.1,
  thinkingLevel = "",
  jsonMode = false,
  timeoutMs = REQUEST_TIMEOUT_MS
} = {}) {
  if (!model) {
    throw new GeminiError("No model given. Pass one, set GEMINI_MODEL, or use the defaults.");
  }

  const generationConfig = { temperature, maxOutputTokens };
  // JSON mode makes the reply syntactically valid JSON (no fences, no dropped quotes).
  if (jsonMode) {
    generationConfig.responseMimeType = "application/json";
  }
  if (thinkingLevel) {
    generationConfig.thinkingConfig = { thinkingLevel };
  }

  const body = { contents: [{ role: "user", parts: [{ text: user.trim() }] }], generationConfig };
  if (system?.trim()) {
    body.systemInstruction = { parts: [{ text: system.trim() }] };
  }

  const path = stream
    ? `models/${model}:streamGenerateContent?alt=sse`
    : `models/${model}:generateContent`;
  const res = await request(path, { method: "POST", body, timeoutMs });

  const emptyReason = (data) =>
    `finishReason=${data.finishReason ?? "unknown"}${data.blockReason ? `, blockReason=${data.blockReason}` : ""}`;

  if (!stream) {
    const data = await res.json();
    const candidate = data.candidates?.[0];
    const content = answerText(candidate).trim();

    if (!content) {
      throw new GeminiError(`Gemini returned no text (${model}), ${emptyReason({
        finishReason: candidate?.finishReason, blockReason: data.promptFeedback?.blockReason
      })}`, { kind: "empty" });
    }

    return { content, usage: data.usageMetadata ?? null, finishReason: candidate?.finishReason ?? null };
  }

  if (!res.body) {
    throw new GeminiError("Gemini response has no body to stream");
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let result = "";
  let usage = null;
  let finishReason = null;
  let blockReason = null;

  process.stdout.write("[LLM] ");

  const flushLine = (line) => {
    const trimmed = line.trim();
    if (!trimmed.startsWith("data:")) {
      return;
    }

    let payload;
    try {
      payload = JSON.parse(trimmed.slice(5).trim());
    } catch {
      return;
    }

    if (payload.error) {
      const info = classifyGeminiError(payload.error.code, JSON.stringify(payload));
      throw new GeminiError(`Gemini stream error (${model}): ${info.message}`, {
        status: payload.error.code ?? null, kind: info.kind, retryAfterSeconds: info.retryAfterSeconds
      });
    }

    const candidate = payload.candidates?.[0];
    const text = answerText(candidate);
    if (text) {
      process.stdout.write(text);
      result += text;
    }
    usage = payload.usageMetadata ?? usage;
    finishReason = candidate?.finishReason ?? finishReason;
    blockReason = payload.promptFeedback?.blockReason ?? blockReason;
  };

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split(/\r?\n/);
      buffer = lines.pop() || "";

      for (const line of lines) {
        flushLine(line);
      }
    }

    buffer += decoder.decode();
    if (buffer) {
      flushLine(buffer);
    }
  } catch (error) {
    // Keep the error message off the partial streamed line.
    process.stdout.write("\n");
    if (error instanceof GeminiError) throw error;
    const timedOut = error?.name === "TimeoutError" || error?.name === "AbortError";
    throw new GeminiError(`Gemini stream interrupted (${model}): ${error.message}`, { kind: timedOut ? "timeout" : "network" });
  }

  process.stdout.write("\n");

  if (!result.trim()) {
    throw new GeminiError(`Gemini stream ended with no text (${model}), ${emptyReason({ finishReason, blockReason })}`, { kind: "empty" });
  }

  return { content: result.trim(), usage, finishReason };
}

// A per-minute limit or a brief overload is worth a short wait; the daily limit never is.
// Gives up (throws the last error) when the wait would exceed maxWaitSeconds or retries run out.
export async function callGeminiWithRetry(options = {}, { maxRetries = 2, maxWaitSeconds = 90, onWait = null } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      const started = Date.now();
      const result = await callGemini(options);
      return { ...result, seconds: (Date.now() - started) / 1000, attempts: attempt };
    } catch (error) {
      if (!(error instanceof GeminiError) || !RETRYABLE_KINDS.includes(error.kind) || attempt > maxRetries) {
        throw error;
      }

      const defaultWait = error.kind === "overloaded" || error.kind === "server-error" ? 10 * attempt : 61;
      const waitSeconds = Math.ceil(error.retryAfterSeconds ?? defaultWait) + 1;

      if (waitSeconds > maxWaitSeconds) {
        throw error;
      }

      onWait?.({ error, attempt, waitSeconds });
      await sleep(waitSeconds * 1000);
    }
  }
}

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.