diff --git a/monitor.js b/monitor.js index d78558a..c904d03 100644 --- a/monitor.js +++ b/monitor.js @@ -17,6 +17,8 @@ import { getProviders } from './providers/index.js'; import { probeTeamsProvider } from './providers/anthropic-teams.js'; import { getApiAteamStatus } from './providers/anthropic-api.js'; import { probeShelleyProxy } from './providers/shelley-proxy.js'; +import { probeGeminiProvider } from './providers/gemini.js'; +import { probeXaiProvider } from './providers/xai.js'; import { generateReport, getSeverity } from './report.js'; import { logRun } from './logger.js'; @@ -40,6 +42,10 @@ async function probeProvider(p) { result = await probeShelleyProxy(p.name, p.baseUrl); } else if (p.type === 'api-direct') { result = getApiAteamStatus(); + } else if (p.type === 'gemini-api') { + result = await probeGeminiProvider(p.name, p.baseUrl, p.apiKey); + } else if (p.type === 'xai-api') { + result = await probeXaiProvider(p.name, p.baseUrl, p.apiKey); } else { result = { type: 'unknown', status: 'skipped', severity: 'unknown' }; } diff --git a/providers/index.js b/providers/index.js index dff64ed..291bd49 100644 --- a/providers/index.js +++ b/providers/index.js @@ -17,6 +17,15 @@ function classifyProvider(name, config) { if (name === 'shelley-proxy') return 'shelley-proxy'; if (name === 'api-ateam') return 'api-direct'; if (config.api === 'anthropic-messages' && name.startsWith('team-')) return 'teams-direct'; + // Gemini — classify by baseUrl or name + const baseUrl = (config.baseUrl || '').toLowerCase(); + if (baseUrl.includes('generativelanguage.googleapis.com') || name.toLowerCase().includes('gemini')) { + return 'gemini-api'; + } + // x.ai — classify by baseUrl or name + if (baseUrl.includes('x.ai') || name.toLowerCase().includes('xai') || name.toLowerCase().includes('grok')) { + return 'xai-api'; + } return null; // skip (zai, etc.) } diff --git a/report.js b/report.js index 06d7897..2654bbc 100644 --- a/report.js +++ b/report.js @@ -19,6 +19,21 @@ export function getSeverity(provider) { if (tokenPct > 0.85) return 'warning'; return 'ok'; } + if (provider.type === 'gemini-api') { + if (provider.status === 'exhausted') return 'critical'; + if (provider.status === 'ok') return 'ok'; + return 'unknown'; + } + if (provider.type === 'xai-api') { + if (provider.status === 'rate_limited') return 'critical'; + if (provider.status === 'ok') { + const reqPct = provider.requests_remaining / provider.requests_limit; + const tokPct = provider.tokens_remaining / provider.tokens_limit; + if ((reqPct < 0.1) || (tokPct < 0.1)) return 'warning'; + return 'ok'; + } + return 'unknown'; + } return 'unknown'; } @@ -104,6 +119,34 @@ export function generateReport(result) { } } else if (p.type === 'api-direct') { detail = p.message || 'No billing data available'; + } else if (p.type === 'gemini-api') { + if (p.status === 'invalid_key') { + detail = 'Invalid API key (401)'; + } else if (p.status === 'no_key') { + detail = 'No API key configured'; + } else if (p.status === 'exhausted') { + const retryIn = p.retry_delay_seconds ? formatDuration(p.retry_delay_seconds) : '?'; + const violated = p.quota_violations?.length || 0; + detail = `EXHAUSTED — retry in ${retryIn} | ${violated} quota(s) violated`; + } else if (p.status === 'ok') { + detail = 'Reachable — no quota depth visible'; + } else if (p.status === 'error') { + detail = `Error: ${p.message || 'unknown'}`; + } + } else if (p.type === 'xai-api') { + if (p.status === 'no_key') { + detail = 'No API key configured'; + } else if (p.status === 'invalid_key') { + detail = 'Invalid API key (401)'; + } else if (p.status === 'rate_limited') { + detail = 'Rate limited (429)'; + } else if (p.status === 'ok') { + const reqPct = p.requests_limit ? `${p.requests_remaining}/${p.requests_limit} req` : ''; + const tokPct = p.tokens_limit ? ` | ${p.tokens_remaining?.toLocaleString()}/${p.tokens_limit?.toLocaleString()} tok` : ''; + detail = reqPct + tokPct || 'OK'; + } else if (p.status === 'error') { + detail = `Error: ${p.message || 'unknown'}`; + } } else { detail = p.message || ''; } diff --git a/test.js b/test.js index 9b85ee3..567c28d 100644 --- a/test.js +++ b/test.js @@ -8,6 +8,8 @@ import { readFileSync, existsSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { execSync } from 'child_process'; +import { parseGeminiBody } from './providers/gemini.js'; +import { parseXaiHeaders } from './providers/xai.js'; let passed = 0; let failed = 0; @@ -320,6 +322,99 @@ assert('log file exists after default run', existsSync(logFileAfter)); const logLines = readFileSync(logFileAfter, 'utf-8').trim().split('\n'); assert('log file has at least 2 entries', logLines.length >= 2); +// ── 11. Gemini provider parser ──────────────────────────────────────────────── +console.log('\n── 11. Gemini provider parser ──────────────────────────────────'); + +const gemini429Body = { + error: { + code: 429, + status: 'RESOURCE_EXHAUSTED', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.QuotaFailure', + violations: [ + { + quotaMetric: 'generativelanguage.googleapis.com/generate_content_free_tier_requests', + quotaId: 'GenerateRequestsPerMinutePerProjectPerModel-FreeTier', + quotaDimensions: { location: 'global', model: 'gemini-2.0-flash' }, + }, + { + quotaMetric: 'generativelanguage.googleapis.com/generate_content_free_tier_requests', + quotaId: 'GenerateRequestsPerDayPerProjectPerModel-FreeTier', + quotaDimensions: { location: 'global', model: 'gemini-2.0-flash' }, + }, + ], + }, + { '@type': 'type.googleapis.com/google.rpc.RetryInfo', retryDelay: '43s' }, + ], + }, +}; + +assert('providers/gemini.js exists', existsSync(join(root, 'providers/gemini.js'))); + +const g429 = parseGeminiBody(gemini429Body, 429); +assert('gemini 429: status = exhausted', g429.status === 'exhausted'); +assert('gemini 429: type = gemini-api', g429.type === 'gemini-api'); +assert('gemini 429: quota_violations is array', Array.isArray(g429.quota_violations)); +assert('gemini 429: quota_violations has 2 entries', g429.quota_violations.length === 2); +assert('gemini 429: retry_delay_seconds = 43', g429.retry_delay_seconds === 43); +assert('gemini 429: severity = critical', g429.severity === 'critical'); + +const g200 = parseGeminiBody(null, 200); +assert('gemini 200: status = ok', g200.status === 'ok'); +assert('gemini 200: severity = ok', g200.severity === 'ok'); +assert('gemini 200: quota_violations is empty array', Array.isArray(g200.quota_violations) && g200.quota_violations.length === 0); + +const g401 = parseGeminiBody(null, 401); +assert('gemini 401: status = invalid_key', g401.status === 'invalid_key'); +assert('gemini 401: severity = unknown', g401.severity === 'unknown'); + +// ── 12. x.ai provider parser ───────────────────────────────────────────────── +console.log('\n── 12. x.ai provider parser ────────────────────────────────────'); + +class MockHeaders { + constructor(map) { this._map = map; } + get(name) { return this._map[name.toLowerCase()] ?? null; } +} + +const xai200Headers = new MockHeaders({ + 'x-ratelimit-limit-requests': '1000', + 'x-ratelimit-remaining-requests': '998', + 'x-ratelimit-limit-tokens': '500000', + 'x-ratelimit-remaining-tokens': '499981', +}); +const xai200HeadersNearFull = new MockHeaders({ + 'x-ratelimit-limit-requests': '1000', + 'x-ratelimit-remaining-requests': '5', + 'x-ratelimit-limit-tokens': '500000', + 'x-ratelimit-remaining-tokens': '499981', +}); +const xai429Headers = new MockHeaders({ + 'x-ratelimit-remaining-requests': '0', +}); + +assert('providers/xai.js exists', existsSync(join(root, 'providers/xai.js'))); + +const xNoKey = parseXaiHeaders(null, 200, null); +assert('xai no key: status = no_key', xNoKey.status === 'no_key'); +assert('xai no key: severity = unknown', xNoKey.severity === 'unknown'); + +const x200 = parseXaiHeaders(xai200Headers, 200, 'xai-key'); +assert('xai 200: status = ok', x200.status === 'ok'); +assert('xai 200: requests_remaining = 998', x200.requests_remaining === 998); +assert('xai 200: tokens_remaining = 499981', x200.tokens_remaining === 499981); +assert('xai 200: severity = ok', x200.severity === 'ok'); + +const x200Near = parseXaiHeaders(xai200HeadersNearFull, 200, 'xai-key'); +assert('xai 200 near-full: severity = warning', x200Near.severity === 'warning'); + +const x429 = parseXaiHeaders(xai429Headers, 429, 'xai-key'); +assert('xai 429: status = rate_limited', x429.status === 'rate_limited'); +assert('xai 429: severity = critical', x429.severity === 'critical'); + +const x401 = parseXaiHeaders(null, 401, 'xai-key'); +assert('xai 401: status = invalid_key', x401.status === 'invalid_key'); + // ── Results ────────────────────────────────────────────────────────────────── console.log('\n' + '═'.repeat(50)); console.log(`Tests: ${passed + failed} | Passed: ${passed} | Failed: ${failed}`);