token-monitor/providers/gemini.js
Hannibal Smith 1b4e299461
build: add gemini and xai provider modules
Expands token-monitor with two new provider types:

- providers/gemini.js — Google Gemini API (body-based quota, no headers)
  - Probes generateContent endpoint (1 token), falls back gemini-2.0-flash → gemini-2.5-flash
  - Parses QuotaFailure violations + RetryInfo from 429 JSON body
  - Returns: status, quota_violations[], retry_delay_seconds, severity

- providers/xai.js — x.ai/Grok (OpenAI-compatible header schema)
  - Reads x-ratelimit-{limit,remaining}-{requests,tokens} headers
  - Handles: no_key, ok, rate_limited, invalid_key states
  - Warning threshold: < 10% remaining on requests or tokens

Both providers handle missing API keys gracefully (status: no_key).
Classification via providers/index.js using baseUrl patterns.
140/140 tests passing.

Closes recon findings from trentuna/a-team#91.
2026-04-04 17:52:37 +00:00

131 lines
4.1 KiB
JavaScript

/**
* 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=<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<Object>} 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',
};
}
}