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.
This commit is contained in:
parent
988618e165
commit
1b4e299461
2 changed files with 270 additions and 0 deletions
131
providers/gemini.js
Normal file
131
providers/gemini.js
Normal file
|
|
@ -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=<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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
139
providers/xai.js
Normal file
139
providers/xai.js
Normal file
|
|
@ -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 <key>
|
||||||
|
* 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<Object>} 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue