Two bugs caused all xai providers to show 'error' in the monitor: 1. Double /v1 in URL: models.json baseUrl is https://api.x.ai/v1 (OpenAI- compatible convention), and the probe was appending /v1/chat/completions, producing https://api.x.ai/v1/v1/chat/completions → HTTP 4xx. Fix: strip trailing /vN from baseUrl before constructing the probe URL. 2. Wrong model: probe used grok-3-mini, which requires specific x.ai console permissions not granted to our keys. Keys have access to grok-4-1-fast-reasoning only. Fix: use GET /v1/models instead — lightweight, no model guessing, returns 200 (valid key) or 401 (invalid). Includes available models in result for visibility. 158/158 tests pass (unit tests for parseXaiHeaders unchanged).
154 lines
4.8 KiB
JavaScript
154 lines
4.8 KiB
JavaScript
/**
|
|
* 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 or https://api.x.ai/v1)
|
|
* @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' };
|
|
}
|
|
|
|
// Strip trailing /v1 (or /v2 etc.) — the probe appends its own versioned path.
|
|
// models.json baseUrl often includes the version (e.g. https://api.x.ai/v1)
|
|
// to satisfy the pi framework; we normalise here to avoid doubling it.
|
|
const base = (baseUrl || 'https://api.x.ai').replace(/\/$/, '').replace(/\/v\d+$/, '');
|
|
|
|
try {
|
|
// Use GET /v1/models as the probe: lightweight, no model-name guessing,
|
|
// returns 200 (valid key) or 401 (invalid key). x.ai doesn't expose
|
|
// per-request quota headers the way Anthropic does, so this is the
|
|
// most reliable liveness check.
|
|
const response = await fetch(`${base}/v1/models`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
});
|
|
|
|
if (response.status === 200) {
|
|
let models = [];
|
|
try {
|
|
const body = await response.json();
|
|
models = (body.data || []).map(m => m.id);
|
|
} catch (_) { /* ignore parse errors */ }
|
|
return {
|
|
type: 'xai-api',
|
|
status: 'ok',
|
|
models,
|
|
severity: 'ok',
|
|
};
|
|
}
|
|
|
|
return parseXaiHeaders(response.headers, response.status, apiKey);
|
|
} catch (err) {
|
|
return {
|
|
type: 'xai-api',
|
|
status: 'error',
|
|
message: err.message,
|
|
severity: 'unknown',
|
|
};
|
|
}
|
|
}
|