diff --git a/providers/gemini.js b/providers/gemini.js new file mode 100644 index 0000000..5c78924 --- /dev/null +++ b/providers/gemini.js @@ -0,0 +1,131 @@ +/** + * 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', + }; + } +} diff --git a/providers/xai.js b/providers/xai.js new file mode 100644 index 0000000..0663c59 --- /dev/null +++ b/providers/xai.js @@ -0,0 +1,139 @@ +/** + * xai.js — x.ai (Grok) provider + * + * OpenAI-compatible API. Rate-limit headers on authenticated responses: + * x-ratelimit-limit-requests integer + * x-ratelimit-remaining-requests integer + * x-ratelimit-reset-requests ISO8601 or relative string + * x-ratelimit-limit-tokens integer + * x-ratelimit-remaining-tokens integer + * x-ratelimit-reset-tokens ISO8601 or relative string + * + * Probe: POST /v1/chat/completions + * Authorization: Bearer + * model: 'grok-3-mini' + * max_tokens: 1 + * + * Provider type: 'xai-api' + */ + +/** + * Compute severity for an x.ai result object. + * + * @param {Object} result — partially-built result (status + limit/remaining fields) + * @returns {'critical'|'warning'|'ok'|'unknown'} + */ +function xaiSeverity(result) { + if (result.status === 'rate_limited') return 'critical'; + if (result.status === 'ok') { + const reqPct = (result.requests_limit > 0) + ? result.requests_remaining / result.requests_limit + : null; + const tokPct = (result.tokens_limit > 0) + ? result.tokens_remaining / result.tokens_limit + : null; + if ((reqPct !== null && reqPct < 0.1) || (tokPct !== null && tokPct < 0.1)) return 'warning'; + return 'ok'; + } + return 'unknown'; +} + +/** + * Parse x.ai rate-limit headers from a response. + * Synchronous — suitable for unit tests with mock headers. + * + * @param {Object|null} headers — fetch Response.headers (or compatible mock with .get(name)) + * @param {number} httpStatus — HTTP status code + * @param {string|null} apiKey — API key (null/undefined → no_key result) + * @returns {Object} normalized provider result + */ +export function parseXaiHeaders(headers, httpStatus, apiKey) { + if (!apiKey) { + return { type: 'xai-api', status: 'no_key', severity: 'unknown' }; + } + + if (httpStatus === 401) { + return { type: 'xai-api', status: 'invalid_key', severity: 'unknown' }; + } + + if (httpStatus === 429) { + const remaining = headers + ? parseInt(headers.get('x-ratelimit-remaining-requests'), 10) + : NaN; + return { + type: 'xai-api', + status: 'rate_limited', + requests_remaining: isNaN(remaining) ? 0 : remaining, + severity: 'critical', + }; + } + + if (httpStatus === 200) { + const h = (name) => (headers ? headers.get(name) : null); + + const reqLimit = parseInt(h('x-ratelimit-limit-requests'), 10); + const reqRemaining = parseInt(h('x-ratelimit-remaining-requests'), 10); + const tokLimit = parseInt(h('x-ratelimit-limit-tokens'), 10); + const tokRemaining = parseInt(h('x-ratelimit-remaining-tokens'), 10); + + const result = { + type: 'xai-api', + status: 'ok', + requests_limit: isNaN(reqLimit) ? null : reqLimit, + requests_remaining: isNaN(reqRemaining) ? null : reqRemaining, + tokens_limit: isNaN(tokLimit) ? null : tokLimit, + tokens_remaining: isNaN(tokRemaining) ? null : tokRemaining, + }; + + result.severity = xaiSeverity(result); + return result; + } + + // Other status codes (403, 5xx, etc.) + return { + type: 'xai-api', + status: 'error', + message: `HTTP ${httpStatus}`, + severity: 'unknown', + }; +} + +/** + * Probe an x.ai API endpoint. + * + * @param {string} providerName + * @param {string} baseUrl — base URL (e.g. https://api.x.ai) + * @param {string|null} apiKey — x.ai API key + * @returns {Promise} normalized provider result + */ +export async function probeXaiProvider(providerName, baseUrl, apiKey) { + if (!apiKey) { + return { type: 'xai-api', status: 'no_key', severity: 'unknown' }; + } + + const base = (baseUrl || 'https://api.x.ai').replace(/\/$/, ''); + + try { + const response = await fetch(`${base}/v1/chat/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'grok-3-mini', + max_tokens: 1, + messages: [{ role: 'user', content: 'Hi' }], + }), + }); + + return parseXaiHeaders(response.headers, response.status, apiKey); + } catch (err) { + return { + type: 'xai-api', + status: 'error', + message: err.message, + severity: 'unknown', + }; + } +}