feat(xai): track SuperGrok weekly limit + API usage quota
Fetch OAuth quota from cli-chat-proxy billing, GetGrokCreditsConfig weekly window, and settings plan label so the dashboard matches grok.com usage.
This commit is contained in:
@@ -25,6 +25,15 @@ export default {
|
|||||||
clientId: "b1a00492-073a-47ea-816f-4c329264a828",
|
clientId: "b1a00492-073a-47ea-816f-4c329264a828",
|
||||||
tokenUrl: "https://auth.x.ai/oauth2/token",
|
tokenUrl: "https://auth.x.ai/oauth2/token",
|
||||||
refreshUrl: "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: [
|
models: [
|
||||||
{ id: "grok-4", name: "Grok 4" },
|
{ id: "grok-4", name: "Grok 4" },
|
||||||
@@ -57,4 +66,7 @@ export default {
|
|||||||
endpoint: "https://api.x.ai/v1/responses",
|
endpoint: "https://api.x.ai/v1/responses",
|
||||||
pricingUrl: "https://x.ai/api#pricing",
|
pricingUrl: "https://x.ai/api#pricing",
|
||||||
},
|
},
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits };
|
|||||||
import { getKiroUsage } from "./usage/kiro.js";
|
import { getKiroUsage } from "./usage/kiro.js";
|
||||||
import { getMiniMaxUsage } from "./usage/minimax.js";
|
import { getMiniMaxUsage } from "./usage/minimax.js";
|
||||||
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
|
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
|
||||||
|
import { getXaiUsage } from "./usage/xai.js";
|
||||||
import {
|
import {
|
||||||
getQwenUsage,
|
getQwenUsage,
|
||||||
getIflowUsage,
|
getIflowUsage,
|
||||||
@@ -43,6 +44,7 @@ const USAGE_HANDLERS = {
|
|||||||
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
|
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||||
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
|
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
|
||||||
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, 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) {
|
export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||||
|
|||||||
326
open-sse/services/usage/xai.js
Normal file
326
open-sse/services/usage/xai.js
Normal file
@@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -451,6 +451,48 @@ export function parseQuotaData(provider, data) {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
// Generic fallback for unknown providers
|
// Generic fallback for unknown providers
|
||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const load = () => import("../../open-sse/services/usage.js");
|
|||||||
const SUPPORTED = [
|
const SUPPORTED = [
|
||||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
||||||
"minimax", "minimax-cn", "vercel-ai-gateway",
|
"minimax", "minimax-cn", "vercel-ai-gateway", "xai",
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("usage dispatch", () => {
|
describe("usage dispatch", () => {
|
||||||
|
|||||||
260
tests/unit/xai-usage.test.js
Normal file
260
tests/unit/xai-usage.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user