Docs/Skills and code/src/
src/gemini.js
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.