diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index eeff6622..1ae9ff41 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -25,6 +25,15 @@ export default { clientId: "b1a00492-073a-47ea-816f-4c329264a828", tokenUrl: "https://auth.x.ai/oauth2/token", refreshUrl: "https://auth.x.ai/oauth2/token", + // OAuth-only SuperGrok quota surfaces: + // - url: monthly API usage allotment (JSON) + // - creditsUrl: weekly SuperGrok limit (grpc-web) + // - settingsUrl: plan label (subscription_tier_display) + usage: { + url: "https://cli-chat-proxy.grok.com/v1/billing", + creditsUrl: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", + settingsUrl: "https://cli-chat-proxy.grok.com/v1/settings", + }, }, models: [ { id: "grok-4", name: "Grok 4" }, @@ -57,4 +66,7 @@ export default { endpoint: "https://api.x.ai/v1/responses", pricingUrl: "https://x.ai/api#pricing", }, + features: { + usage: true, + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 4c56dc1b..c789d4b7 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -11,6 +11,7 @@ export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; +import { getXaiUsage } from "./usage/xai.js"; import { getQwenUsage, getIflowUsage, @@ -43,6 +44,7 @@ const USAGE_HANDLERS = { "minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), + xai: (c) => getXaiUsage(c.accessToken, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/xai.js b/open-sse/services/usage/xai.js new file mode 100644 index 00000000..bb108f27 --- /dev/null +++ b/open-sse/services/usage/xai.js @@ -0,0 +1,326 @@ +/** + * xAI (Grok) OAuth usage handler + * + * SuperGrok quota is split across two upstream surfaces (OAuth only): + * + * 1) Monthly / API usage allotment (JSON) + * GET https://cli-chat-proxy.grok.com/v1/billing + * { + * "config": { + * "monthlyLimit": { "val": 15000 }, + * "used": { "val": 733 }, + * "onDemandCap": { "val": 0 }, + * "billingPeriodStart": "...", + * "billingPeriodEnd": "..." + * } + * } + * + * 2) Weekly SuperGrok limit (grpc-web protobuf) + * POST https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig + * Empty request frame; response message1 contains: + * usedPercent (float32), window start/end timestamps, nested windows. + * This is what the grok.com usage page labels "Weekly limit" / "Resets …". + * + * Plan label comes from cli-chat-proxy settings: + * GET https://cli-chat-proxy.grok.com/v1/settings → subscription_tier_display + * + * Note: grok.com/rest/rate-limits is a short chat-window (e.g. 2h query count) + * behind Cloudflare browser cookies — not usable with pure OAuth bearer. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; + +// Empty grpc-web request frame: flag(0) + length(0) + no payload. +const GRPC_WEB_EMPTY_FRAME = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]); + +function moneyVal(wrapper) { + if (wrapper == null) return null; + if (typeof wrapper === "number") return toFiniteNumber(wrapper, null); + if (typeof wrapper === "object" && wrapper.val != null) { + return toFiniteNumber(wrapper.val, null); + } + return null; +} + +function authHeaders(accessToken, extra = {}) { + return { + Authorization: `Bearer ${accessToken}`, + ...extra, + }; +} + +function readVarint(buf, offset) { + let val = 0; + let shift = 0; + let i = offset; + while (i < buf.length) { + const b = buf[i++]; + val |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) return { value: val >>> 0, offset: i }; + shift += 7; + if (shift > 35) break; + } + return null; +} + +/** + * Minimal protobuf decoder for GetGrokCreditsConfig. + * Only understands varint / fixed32 / fixed64 / length-delimited. + */ +function parseProtobufFields(buf) { + const fields = []; + let i = 0; + while (i < buf.length) { + const key = readVarint(buf, i); + if (!key) break; + i = key.offset; + const field = key.value >>> 3; + const wt = key.value & 7; + + if (wt === 0) { + const v = readVarint(buf, i); + if (!v) break; + i = v.offset; + fields.push({ field, type: "varint", value: v.value }); + } else if (wt === 1) { + if (i + 8 > buf.length) break; + fields.push({ field, type: "fixed64", value: buf.subarray(i, i + 8) }); + i += 8; + } else if (wt === 5) { + if (i + 4 > buf.length) break; + fields.push({ + field, + type: "fixed32", + value: buf.readFloatLE(i), + }); + i += 4; + } else if (wt === 2) { + const ln = readVarint(buf, i); + if (!ln) break; + i = ln.offset; + if (i + ln.value > buf.length) break; + fields.push({ + field, + type: "bytes", + value: buf.subarray(i, i + ln.value), + }); + i += ln.value; + } else { + break; + } + } + return fields; +} + +function parseTimestamp(bytes) { + if (!bytes || !bytes.length) return null; + const fields = parseProtobufFields(bytes); + const seconds = fields.find((f) => f.field === 1 && f.type === "varint")?.value; + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return new Date(seconds * 1000).toISOString(); +} + +/** + * Parse grpc-web response bytes from GetGrokCreditsConfig. + * Returns { usedPercent, resetAt, periodStart } or null. + */ +export function parseGrokCreditsConfig(raw) { + if (!raw || !raw.length) return null; + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + + // grpc-web data frame: 1-byte flag + 4-byte big-endian length + message + if (buf.length < 5) return null; + const flag = buf[0]; + // Data frames have flag 0; ignore trailer frames (flag 0x80). + if (flag !== 0) return null; + const msgLen = buf.readUInt32BE(1); + if (msgLen <= 0 || 5 + msgLen > buf.length) return null; + const msg = buf.subarray(5, 5 + msgLen); + + // Response is typically { 1: CreditsConfig } + const top = parseProtobufFields(msg); + const configBytes = top.find((f) => f.field === 1 && f.type === "bytes")?.value || msg; + const fields = parseProtobufFields(configBytes); + + const usedPercentRaw = fields.find((f) => f.field === 1 && f.type === "fixed32")?.value; + const periodStart = parseTimestamp(fields.find((f) => f.field === 4 && f.type === "bytes")?.value); + const periodEnd = parseTimestamp(fields.find((f) => f.field === 5 && f.type === "bytes")?.value); + + if (usedPercentRaw == null || !Number.isFinite(usedPercentRaw)) return null; + + const usedPercent = Math.max(0, Math.min(100, usedPercentRaw)); + return { + usedPercent, + remainingPercent: Math.max(0, 100 - usedPercent), + periodStart, + resetAt: periodEnd, + }; +} + +async function fetchBilling(accessToken, billingUrl, proxyOptions) { + const response = await proxyAwareFetch(billingUrl, { + method: "GET", + headers: authHeaders(accessToken, { Accept: "application/json" }), + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { error: "auth", status: response.status }; + } + if (!response.ok) { + return { error: "http", status: response.status }; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== "object") { + return { error: "json" }; + } + return { data }; +} + +async function fetchWeeklyCredits(accessToken, creditsUrl, proxyOptions) { + if (!creditsUrl) return null; + try { + const response = await proxyAwareFetch(creditsUrl, { + method: "POST", + headers: authHeaders(accessToken, { + "Content-Type": "application/grpc-web+proto", + "x-grpc-web": "1", + "x-user-agent": "connect-es/2.1.1", + Accept: "*/*", + Origin: "https://grok.com", + Referer: "https://grok.com/?_s=usage", + }), + body: GRPC_WEB_EMPTY_FRAME, + }, proxyOptions); + + if (!response.ok) return null; + const ab = await response.arrayBuffer(); + return parseGrokCreditsConfig(Buffer.from(ab)); + } catch { + return null; + } +} + +async function fetchPlanLabel(accessToken, settingsUrl, proxyOptions) { + if (!settingsUrl) return null; + try { + const response = await proxyAwareFetch(settingsUrl, { + method: "GET", + headers: authHeaders(accessToken, { Accept: "application/json" }), + }, proxyOptions); + if (!response.ok) return null; + const data = await response.json().catch(() => null); + const label = data?.subscription_tier_display; + return typeof label === "string" && label.trim() ? label.trim() : null; + } catch { + return null; + } +} + +/** + * @param {string} accessToken - xAI OAuth access token + * @param {object|null} proxyOptions + */ +export async function getXaiUsage(accessToken, proxyOptions = null) { + if (!accessToken) { + return { message: "xAI usage unavailable: no access token. Re-authorize the connection." }; + } + + const cfg = U("xai") || {}; + const billingUrl = cfg.url; + if (!billingUrl) { + return { message: "xAI usage endpoint is not configured." }; + } + + try { + const [billingResult, weekly, planLabel] = await Promise.all([ + fetchBilling(accessToken, billingUrl, proxyOptions), + fetchWeeklyCredits(accessToken, cfg.creditsUrl, proxyOptions), + fetchPlanLabel(accessToken, cfg.settingsUrl, proxyOptions), + ]); + + if (billingResult.error === "auth") { + return { message: "xAI OAuth token expired or unauthorized. Please re-authorize." }; + } + + const quotas = {}; + let periodStart = null; + let periodEnd = null; + let onDemandCap = 0; + + if (billingResult.data) { + const config = + billingResult.data.config && typeof billingResult.data.config === "object" + ? billingResult.data.config + : billingResult.data; + const monthlyLimit = moneyVal(config.monthlyLimit); + const used = moneyVal(config.used); + onDemandCap = moneyVal(config.onDemandCap) ?? 0; + periodEnd = parseResetTime(config.billingPeriodEnd); + periodStart = parseResetTime(config.billingPeriodStart); + + // Absolute credit counts — do NOT put remaining credits on `remaining` + // (QuotaTable treats remaining as a 0-100 percentage; same pitfall as Qoder). + if (monthlyLimit != null && monthlyLimit > 0) { + const usedSafe = Math.max(0, used ?? 0); + quotas.api_usage = { + used: usedSafe, + total: monthlyLimit, + remainingCredits: Math.max(0, monthlyLimit - usedSafe), + unit: "credits", + resetAt: periodEnd, + unlimited: false, + }; + } + + if (onDemandCap > 0) { + quotas.on_demand = { + used: 0, + total: onDemandCap, + remainingCredits: onDemandCap, + unit: "credits", + resetAt: periodEnd, + unlimited: false, + }; + } + } + + // Weekly SuperGrok window — percentage-based like Claude/Codex windows. + if (weekly) { + quotas.weekly = { + used: weekly.usedPercent, + total: 100, + remaining: weekly.remainingPercent, + remainingPercentage: weekly.remainingPercent, + resetAt: weekly.resetAt || null, + unlimited: false, + }; + if (!periodStart && weekly.periodStart) periodStart = weekly.periodStart; + } + + if (Object.keys(quotas).length === 0) { + const statusHint = + billingResult.error === "http" + ? ` Billing API temporarily unavailable (${billingResult.status}).` + : ""; + return { + plan: planLabel || "xAI", + message: `xAI connected. No quota allotment reported for this account.${statusHint}`, + periodStart, + periodEnd, + quotas: {}, + }; + } + + return { + plan: planLabel || "xAI", + periodStart, + periodEnd, + onDemandCap, + quotas, + }; + } catch (error) { + return { message: `xAI connected. Unable to fetch billing: ${error.message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 688f0ab7..0e6101dc 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -451,6 +451,48 @@ export function parseQuotaData(provider, data) { } break; + case "xai": + // xAI mixes: + // - weekly: percentage window (used/total 0-100) from GetGrokCreditsConfig + // - api_usage: absolute monthly credits from /v1/billing + // For absolute rows, do not forward remainingCredits as `remaining` + // (QuotaTable treats remaining as a 0-100 percentage; same pitfall as Qoder). + if (data.quotas) { + Object.entries(data.quotas).forEach(([quotaType, quota]) => { + const name = + quotaType === "weekly" + ? "Weekly limit" + : quotaType === "api_usage" + ? "Api usage" + : quotaType === "monthly" + ? "Api usage" + : quotaType === "on_demand" + ? "On-demand" + : quotaType; + + if (quotaType === "weekly") { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 100, + remaining: quota.remaining, + remainingPercentage: quota.remainingPercentage ?? quota.remaining, + resetAt: quota.resetAt || null, + }); + return; + } + + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + unit: quota.unit, + resetAt: quota.resetAt || null, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index e4d4ac82..c88d02c7 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -15,7 +15,7 @@ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", - "minimax", "minimax-cn", "vercel-ai-gateway", + "minimax", "minimax-cn", "vercel-ai-gateway", "xai", ]; describe("usage dispatch", () => { diff --git a/tests/unit/xai-usage.test.js b/tests/unit/xai-usage.test.js new file mode 100644 index 00000000..2a6fdd63 --- /dev/null +++ b/tests/unit/xai-usage.test.js @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { parseGrokCreditsConfig } from "../../open-sse/services/usage/xai.js"; +import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +function billingResponse(config) { + return new Response(JSON.stringify({ config }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function settingsResponse(tier = "SuperGrok") { + return new Response(JSON.stringify({ subscription_tier_display: tier }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Build a minimal grpc-web GetGrokCreditsConfig payload matching the live + * SuperGrok response shape: usedPercent float + start/end timestamps. + */ +function buildCreditsGrpcFrame({ usedPercent = 18, startSec = 1783673158, endSec = 1784277958 }) { + // Encode protobuf Timestamp {1: seconds} + const encodeVarint = (n) => { + const out = []; + let v = n >>> 0; + while (v >= 0x80) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); + return Buffer.from(out); + }; + const encodeKey = (field, wt) => encodeVarint((field << 3) | wt); + const encodeTimestamp = (seconds) => { + const body = Buffer.concat([encodeKey(1, 0), encodeVarint(seconds)]); + return body; + }; + const encodeLen = (field, bytes) => + Buffer.concat([encodeKey(field, 2), encodeVarint(bytes.length), bytes]); + const encodeFloat = (field, f) => { + const buf = Buffer.alloc(4); + buf.writeFloatLE(f, 0); + return Buffer.concat([encodeKey(field, 5), buf]); + }; + + const startTs = encodeTimestamp(startSec); + const endTs = encodeTimestamp(endSec); + const config = Buffer.concat([ + encodeFloat(1, usedPercent), + encodeLen(4, startTs), + encodeLen(5, endTs), + ]); + const msg = encodeLen(1, config); + const frame = Buffer.alloc(5 + msg.length); + frame[0] = 0; + frame.writeUInt32BE(msg.length, 1); + msg.copy(frame, 5); + return frame; +} + +function creditsResponse(opts = {}) { + const frame = buildCreditsGrpcFrame(opts); + return new Response(frame, { + status: 200, + headers: { "Content-Type": "application/grpc-web+proto" }, + }); +} + +function mockXaiHappyPath({ used = 733, limit = 15000, weeklyPercent = 18 } = {}) { + proxyAwareFetch.mockImplementation(async (url) => { + if (String(url).includes("/v1/billing")) { + return billingResponse({ + monthlyLimit: { val: limit }, + used: { val: used }, + onDemandCap: { val: 0 }, + billingPeriodStart: "2026-07-01T00:00:00+00:00", + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }); + } + if (String(url).includes("GetGrokCreditsConfig")) { + return creditsResponse({ usedPercent: weeklyPercent }); + } + if (String(url).includes("/v1/settings")) { + return settingsResponse("SuperGrok"); + } + return new Response("{}", { status: 404 }); + }); +} + +describe("xAI usage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("parseGrokCreditsConfig extracts weekly used% and reset timestamp", () => { + const frame = buildCreditsGrpcFrame({ + usedPercent: 18, + startSec: 1783673158, + endSec: 1784277958, + }); + const parsed = parseGrokCreditsConfig(frame); + expect(parsed).toMatchObject({ + usedPercent: 18, + remainingPercent: 82, + }); + expect(parsed.resetAt).toBe(new Date(1784277958 * 1000).toISOString()); + expect(parsed.periodStart).toBe(new Date(1783673158 * 1000).toISOString()); + }); + + it("fetches billing + weekly credits + settings in parallel", async () => { + mockXaiHappyPath(); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "tok-abc", + }); + + const urls = proxyAwareFetch.mock.calls.map((c) => String(c[0])); + expect(urls).toEqual( + expect.arrayContaining([ + "https://cli-chat-proxy.grok.com/v1/billing", + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", + "https://cli-chat-proxy.grok.com/v1/settings", + ]), + ); + + expect(usage.plan).toBe("SuperGrok"); + expect(usage.quotas.weekly).toMatchObject({ + used: 18, + total: 100, + remaining: 82, + }); + expect(usage.quotas.api_usage).toMatchObject({ + used: 733, + total: 15000, + unit: "credits", + }); + expect(usage.quotas.api_usage.resetAt).toBe("2026-08-01T00:00:00.000Z"); + }); + + it("still returns weekly when billing fails but credits succeed", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (String(url).includes("/v1/billing")) { + return new Response("nope", { status: 503 }); + } + if (String(url).includes("GetGrokCreditsConfig")) { + return creditsResponse({ usedPercent: 42 }); + } + if (String(url).includes("/v1/settings")) { + return settingsResponse("SuperGrok"); + } + return new Response("{}", { status: 404 }); + }); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "tok", + }); + + expect(usage.plan).toBe("SuperGrok"); + expect(usage.quotas.weekly).toMatchObject({ used: 42, total: 100, remaining: 58 }); + expect(usage.quotas.api_usage).toBeUndefined(); + }); + + it("includes on-demand row when onDemandCap > 0", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (String(url).includes("/v1/billing")) { + return billingResponse({ + monthlyLimit: { val: 1000 }, + used: { val: 100 }, + onDemandCap: { val: 500 }, + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }); + } + if (String(url).includes("GetGrokCreditsConfig")) { + return creditsResponse({ usedPercent: 10 }); + } + if (String(url).includes("/v1/settings")) { + return settingsResponse("SuperGrok"); + } + return new Response("{}", { status: 404 }); + }); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "tok", + }); + + expect(usage.quotas.on_demand).toMatchObject({ + used: 0, + total: 500, + remainingCredits: 500, + }); + }); + + it("returns auth message on 401 billing", async () => { + proxyAwareFetch.mockImplementation(async (url) => { + if (String(url).includes("/v1/billing")) { + return new Response("unauthorized", { status: 401 }); + } + return new Response("{}", { status: 404 }); + }); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "tok", + }); + + expect(usage.message).toMatch(/re-authorize/i); + }); + + it("parseQuotaData maps weekly + api_usage labels", () => { + const rows = parseQuotaData("xai", { + quotas: { + weekly: { + used: 18, + total: 100, + remaining: 82, + remainingPercentage: 82, + resetAt: "2026-07-17T08:45:58.000Z", + }, + api_usage: { + used: 733, + total: 15000, + remainingCredits: 14267, + unit: "credits", + resetAt: "2026-08-01T00:00:00.000Z", + }, + }, + }); + + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Weekly limit", + used: 18, + total: 100, + remaining: 82, + }), + expect.objectContaining({ + name: "Api usage", + used: 733, + total: 15000, + unit: "credits", + }), + ]), + ); + const api = rows.find((r) => r.name === "Api usage"); + expect(api.remaining).toBeUndefined(); + }); +});