// Shared helpers for the Gemini probes. No secrets here: the key comes from env GOOGLE_GEMINI_API_KEY. import { GoogleGenAI } from "@google/genai"; import { mkdirSync, writeFileSync } from "node:fs"; export const KEY = process.env.GOOGLE_GEMINI_API_KEY ?? ""; if (!KEY) throw new Error("GOOGLE_GEMINI_API_KEY missing (set -a; . ../../.env; set +a)"); export const ai = new GoogleGenAI({ apiKey: KEY, httpOptions: { timeout: 120_000 } }); // Requested set was 3.8-flash, 3.5-flash, 3.1-pro-preview, 2.5-flash, 2.5-pro, omni — but with this free-tier key // 2.5-* return 404 "no longer available to new users" and 3.1-pro / omni have free-tier quota 0 (see out/06-availability.json). // Default probe set = accessible models; override with argv[2] = comma-separated ids. export const DEFAULT_MODELS = [ "gemini-3.8-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-flash-lite", "gemini-3-flash-preview", "gemma-4-31b-it", ]; export const MODELS = process.argv[2] ? process.argv[2].split(",") : DEFAULT_MODELS; export const SUFFIX = process.argv[2] ? "-" + process.argv[2].replace(/[^a-z0-9.-]/gi, "_") : ""; mkdirSync("out", { recursive: true }); export function save(name: string, data: unknown) { writeFileSync(`out/${name}`, JSON.stringify(data, redact, 2)); } // Never let a key leak into saved output. function redact(_k: string, v: unknown) { if (typeof v === "string" && KEY && v.includes(KEY)) return v.replaceAll(KEY, "[REDACTED]"); return v; } export function errInfo(e: any) { // SDK throws ApiError {name, message, status}. message is usually the JSON error body as string. let parsed: any = null; const m: string = e?.message ?? String(e); try { parsed = JSON.parse(m); } catch { /* not json */ } const inner = parsed?.error ?? parsed; return { name: e?.name, httpStatus: e?.status ?? inner?.code ?? null, status: inner?.status ?? null, message: (inner?.message ?? m).slice(0, 400), details: inner?.details ?? null, }; } export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** Classify a 429: per-minute quota → retryable; per-day / limit 0 → give up for this model. */ export function quotaKind(e: any): "minute" | "day" | "other" { const info = errInfo(e); if (info.httpStatus !== 429) return "other"; const q = (info.details ?? []).find((d: any) => String(d["@type"]).includes("QuotaFailure")); const ids: string[] = (q?.violations ?? []).map((v: any) => v.quotaId); if (ids.some((i) => /PerDay/.test(i))) return "day"; if (ids.some((i) => /PerMinute/.test(i))) return "minute"; return "other"; } /** Retry on per-minute 429 (honouring RetryInfo.retryDelay) and on 503 high demand; max `tries`. */ export async function withRetry(fn: () => Promise, tries = 4): Promise { let last: any; for (let i = 0; i < tries; i++) { try { return await fn(); } catch (e: any) { last = e; const info = errInfo(e); const kind = quotaKind(e); if (kind === "minute" || info.httpStatus === 503) { const ri = (info.details ?? []).find((d: any) => String(d["@type"]).includes("RetryInfo")); const secs = ri?.retryDelay ? parseInt(String(ri.retryDelay)) + 2 : 20; console.log(` ..${info.httpStatus} (${kind}); waiting ${secs}s then retry ${i + 1}/${tries - 1}`); await sleep(secs * 1000); continue; } throw e; } } throw last; } /** Pace requests to stay under the free-tier RPM for a given model. */ export const PACE_MS = Number(process.env.PROBE_PACE_MS ?? 7000); export const shortText = (r: any) => (r?.candidates?.[0]?.content?.parts ?? []).filter((p: any) => p.text && !p.thought).map((p: any) => p.text).join("").slice(0, 120);