diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1f69ad4b..c0fcbbc2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features - **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI +- **xAI**: show OAuth account plan and available credits in Quota Tracker - **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml` - **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages` - **Kiro**: add GPT-5.6 model family (#2596) @@ -407,4 +408,4 @@ # v0.4.46 (2026-05-15) ## Breaking Changes -- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL \ No newline at end of file +- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index 53a73c07ae..99aeb59595 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -1,3 +1,5 @@ +import { GROK_CLI_BASE_URL } from "../../config/grokCli.js"; + export default { id: "xai", priority: 280, @@ -25,6 +27,12 @@ export default { clientId: "b1a00492-073a-47ea-816f-4c329264a828", tokenUrl: "https://auth.x.ai/oauth2/token", refreshUrl: "https://auth.x.ai/oauth2/token", + // xAI OAuth grants include grok-cli:access, so the same token can read + // the billing and subscription endpoints used by the official Grok CLI. + usage: { + url: `${GROK_CLI_BASE_URL}/billing?format=credits`, + userUrl: `${GROK_CLI_BASE_URL}/user?include=subscription`, + }, }, models: [ { id: "grok-4", name: "Grok 4" }, @@ -44,4 +52,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 43bfc8ed2d..fcf9e7c7ea 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -11,7 +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 { getGrokCliUsage } from "./usage/grok-cli.js"; +import { getGrokCliUsage, getXaiUsage } from "./usage/grok-cli.js"; import { getQwenUsage, getIflowUsage, @@ -45,6 +45,7 @@ const USAGE_HANDLERS = { "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + xai: (c) => getXaiUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 865baeb058..ad1d4cb5db 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -30,10 +30,6 @@ import { GROK_CLI_VERSION, } from "../../config/grokCli.js"; -const USAGE = U("grok-cli"); -const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; -const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; - /** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ function unwrapVal(value, fallback = 0) { if (value == null) return fallback; @@ -261,41 +257,50 @@ export function parseGrokCliBilling(billing, user = null) { * @param {object|null} providerSpecificData * @param {object|null} proxyOptions */ -export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) { +async function getGrokUsage( + provider, + providerLabel, + accessToken, + providerSpecificData = null, + proxyOptions = null, +) { if (!accessToken) { - return { message: "Grok CLI access token not available." }; + return { message: `${providerLabel} access token not available.` }; } + const usage = U(provider); + const billingUrl = usage.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; + const userUrl = usage.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; const headers = buildGrokCliHeaders(accessToken, providerSpecificData); try { // Fetch billing + user profile in parallel (same pattern as official CLI startup) const [billingRes, userRes] = await Promise.all([ proxyAwareFetch( - BILLING_URL, + billingUrl, { method: "GET", headers }, proxyOptions, ), proxyAwareFetch( - USER_URL, + userUrl, { method: "GET", headers }, proxyOptions, ).catch(() => null), ]); if (billingRes.status === 401 || billingRes.status === 403) { - return { message: "Grok CLI authentication expired. Please re-authorize." }; + return { message: `${providerLabel} authentication expired. Please re-authorize.` }; } if (!billingRes.ok) { const errText = await billingRes.text().catch(() => ""); const trimmed = errText ? `: ${errText.slice(0, 200)}` : ""; - return { message: `Grok CLI billing API error (${billingRes.status})${trimmed}` }; + return { message: `${providerLabel} billing API error (${billingRes.status})${trimmed}` }; } const billing = await billingRes.json().catch(() => null); if (!billing || typeof billing !== "object") { - return { message: "Grok CLI billing response was not JSON." }; + return { message: `${providerLabel} billing response was not JSON.` }; } let user = null; @@ -323,6 +328,14 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null, quotas: parsed.quotas, }; } catch (error) { - return { message: `Grok CLI usage error: ${error.message}` }; + return { message: `${providerLabel} usage error: ${error.message}` }; } } + +export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) { + return getGrokUsage("grok-cli", "Grok CLI", accessToken, providerSpecificData, proxyOptions); +} + +export async function getXaiUsage(accessToken, providerSpecificData = null, proxyOptions = null) { + return getGrokUsage("xai", "xAI", accessToken, providerSpecificData, proxyOptions); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 7befe9fba1..21a70d4ead 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -476,6 +476,7 @@ export function parseQuotaData(provider, data) { break; case "grok-cli": + case "xai": // Grok Build credits (on-demand window + prepaid balance). // Do NOT forward absolute `remaining` — getRemainingPercentage treats // it as a 0–100 percentage (same as Qoder). Use remainingPercentage. diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 0c52a2cd20..ceaa36e422 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -70,6 +70,18 @@ describe("grok-cli registry usage flag", () => { }); }); +describe("xai registry usage flag", () => { + it("exposes Grok billing transport urls", () => { + const cfg = PROVIDERS.xai; + expect(cfg.usage?.url).toContain("/v1/billing"); + expect(cfg.usage?.userUrl).toContain("/v1/user"); + }); + + it("is listed in USAGE_SUPPORTED_PROVIDERS", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("xai"); + }); +}); + describe("parseGrokCliBilling", () => { it("maps on-demand cap/used + prepaid balance", () => { const parsed = parseGrokCliBilling(ACTIVE_BILLING, USER_PROFILE); @@ -225,6 +237,47 @@ describe("getUsageForProvider(grok-cli)", () => { }); }); +describe("getUsageForProvider(xai)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reuses Grok billing endpoints for xAI OAuth connections", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(ACTIVE_BILLING)) + .mockResolvedValueOnce(jsonResponse(USER_PROFILE)); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "xai-oauth-token", + }); + + expect(usage.plan).toBe("Grok Code"); + expect(usage.quotas["On-demand"]).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + expect(proxyAwareFetch.mock.calls[0][0]).toContain("/v1/billing"); + expect(proxyAwareFetch.mock.calls[0][1].headers.Authorization).toBe( + "Bearer xai-oauth-token", + ); + }); + + it("uses an xAI-specific reauthorization message", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)) + .mockResolvedValueOnce(jsonResponse(USER_PROFILE)); + + const usage = await getUsageForProvider({ + provider: "xai", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/^xAI authentication expired/); + }); +}); + describe("parseQuotaData(grok-cli)", () => { it("forwards remainingPercentage for dashboard bars", () => { const rows = parseQuotaData("grok-cli", { @@ -248,4 +301,23 @@ describe("parseQuotaData(grok-cli)", () => { remainingPercentage: 65, }); }); + + it("preserves remainingPercentage for xAI OAuth quotas", () => { + const rows = parseQuotaData("xai", { + quotas: { + "On-demand": { + used: 35, + total: 100, + remainingPercentage: 65, + resetAt: "2026-07-15T00:00:00.000Z", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "On-demand", + remainingPercentage: 65, + }); + }); }); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 75096d781e..0d2d0e2d74 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", "grok-cli", + "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "xai", ]; describe("usage dispatch", () => {