/** * gemini.js — Google Gemini API provider * * Gemini returns zero rate-limit headers. Quota state is embedded in 429 JSON body. * On 200: liveness confirmed, quota depth unavailable. * On 429 (RESOURCE_EXHAUSTED): parse error.details for QuotaFailure + RetryInfo. * * Probe endpoint: POST /v1beta/models/gemini-2.0-flash:generateContent?key= * Body: {"contents":[{"parts":[{"text":"Hi"}]}],"generationConfig":{"maxOutputTokens":1}} * * Provider type in index.js: 'gemini-api' * * Quota from 429 body: * error.details[].@type === 'type.googleapis.com/google.rpc.QuotaFailure' * → .violations[].quotaId (e.g. 'GenerateRequestsPerDayPerProjectPerModel-FreeTier') * → .violations[].quotaMetric * error.details[].@type === 'type.googleapis.com/google.rpc.RetryInfo' * → .retryDelay (e.g. '43s' — parse to integer seconds) */ /** * Parse a Gemini API response body and HTTP status into a normalized result. * Synchronous — suitable for unit tests with mock data. * * @param {Object|null} body — parsed JSON response body * @param {number} httpStatus — HTTP status code * @returns {Object} normalized provider result */ export function parseGeminiBody(body, httpStatus) { if (httpStatus === 401 || httpStatus === 403) { return { type: 'gemini-api', status: 'invalid_key', severity: 'unknown' }; } if (httpStatus === 429) { const details = body?.error?.details || []; // Extract quota violations from QuotaFailure detail const quotaFailure = details.find( (d) => d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure' ); const quotaViolations = (quotaFailure?.violations || []) .map((v) => v.quotaId || v.quotaMetric || '') .filter(Boolean); // Extract retry delay from RetryInfo detail (e.g. '43s' → 43) const retryInfo = details.find( (d) => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo' ); let retryDelaySeconds = null; if (retryInfo?.retryDelay) { const match = String(retryInfo.retryDelay).match(/^(\d+)/); retryDelaySeconds = match ? parseInt(match[1], 10) : null; } return { type: 'gemini-api', status: 'exhausted', quota_violations: quotaViolations, retry_delay_seconds: retryDelaySeconds, severity: 'critical', }; } if (httpStatus === 200) { return { type: 'gemini-api', status: 'ok', quota_violations: [], retry_delay_seconds: null, severity: 'ok', }; } // Other errors (404, 5xx, etc.) return { type: 'gemini-api', status: 'error', message: body?.error?.message || `HTTP ${httpStatus}`, severity: 'unknown', }; } /** * Probe a Gemini provider endpoint. * Tries gemini-2.0-flash first; falls back to gemini-2.5-flash on 404. * * @param {string} providerName * @param {string} baseUrl — base URL (e.g. https://generativelanguage.googleapis.com) * @param {string|null} apiKey — Google API key * @returns {Promise} normalized provider result */ export async function probeGeminiProvider(providerName, baseUrl, apiKey) { if (!apiKey) { return { type: 'gemini-api', status: 'no_key', severity: 'unknown' }; } const base = (baseUrl || 'https://generativelanguage.googleapis.com').replace(/\/$/, ''); async function tryModel(model) { const url = `${base}/v1beta/models/${model}:generateContent?key=${apiKey}`; const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: 'Hi' }] }], generationConfig: { maxOutputTokens: 1 }, }), }); let body = null; try { body = await response.json(); } catch (_) {} return { status: response.status, body }; } try { let { status, body } = await tryModel('gemini-2.0-flash'); // 404 = model not found, try fallback if (status === 404) { ({ status, body } = await tryModel('gemini-2.5-flash')); } return parseGeminiBody(body, status); } catch (err) { return { type: 'gemini-api', status: 'error', message: err.message, severity: 'unknown', }; } }