From b46dc1ad9ea9fec8c0ab8fe00a1be66d6dbd0e53 Mon Sep 17 00:00:00 2001 From: H-TTTTT <36735327+H-TTTTT@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:51 +0800 Subject: [PATCH] feat(provider): add xAI SuperGrok weekly quota --- README.md | 1 + docs/readme/providers.md | 1 + src/lib/provider-metadata.ts | 12 ++ src/lib/quota-status.ts | 10 ++ src/lib/types.ts | 9 + src/lib/xai.ts | 190 +++++++++++++++++++++ src/providers/registry.ts | 2 + src/providers/xai.ts | 70 ++++++++ tests/helpers/provider-assertions.ts | 8 + tests/lib.provider-metadata.test.ts | 17 ++ tests/lib.quota-status.test.ts | 34 ++++ tests/lib.xai.test.ts | 245 +++++++++++++++++++++++++++ tests/providers.xai.test.ts | 120 +++++++++++++ 13 files changed, 719 insertions(+) create mode 100644 src/lib/xai.ts create mode 100644 src/providers/xai.ts create mode 100644 tests/lib.xai.test.ts create mode 100644 tests/providers.xai.test.ts diff --git a/README.md b/README.md index c111e34..88c67ce 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o | Anthropic (Claude) | [Needs setup](docs/readme/providers.md#anthropic-claude) | Local CLI/OAuth | Quota | | GitHub Copilot | [Needs setup](docs/readme/providers.md#github-copilot) | Remote API | Usage and budget | | OpenAI | Automatic | Remote API | Quota | +| xAI SuperGrok | OpenCode `/connect` xAI | Remote API | Quota | | Cursor | [Needs setup](docs/readme/providers.md#cursor) | Local estimate | Budget and spend | | Qwen Code | [Needs setup](docs/readme/providers.md#qwen-code) | Local estimate | Quota | | Alibaba Coding Plan | OpenCode config | Local estimate | Quota | diff --git a/docs/readme/providers.md b/docs/readme/providers.md index eb5c05b..5360485 100644 --- a/docs/readme/providers.md +++ b/docs/readme/providers.md @@ -19,6 +19,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o | Anthropic (Claude) | [Needs setup](#anthropic-claude) | Local CLI/OAuth | Quota | | GitHub Copilot | [Needs setup](#github-copilot) | Remote API | Usage and budget | | OpenAI | Automatic | Remote API | Quota | +| xAI SuperGrok | OpenCode `/connect` xAI | Remote API | Quota | | Cursor | [Needs setup](#cursor) | Local estimate | Budget and spend | | Qwen Code | [Needs setup](#qwen-code) | Local estimate | Quota | | Alibaba Coding Plan | OpenCode config | Local estimate | Quota | diff --git a/src/lib/provider-metadata.ts b/src/lib/provider-metadata.ts index 3b2f529..12ff5df 100644 --- a/src/lib/provider-metadata.ts +++ b/src/lib/provider-metadata.ts @@ -17,6 +17,7 @@ export type CanonicalQuotaProviderId = | "minimax-china-coding-plan" | "kimi-for-coding" | "deepseek" + | "xai" | "opencode-go" | "ollama-cloud" | "quota-providers"; @@ -73,6 +74,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly> = { "kimi-for-coding": "Kimi Code", "kimi-code": "Kimi Code", deepseek: "DeepSeek", + xai: "xAI", "opencode-go": "OpenCode Go", "ollama-cloud": "Ollama Cloud", "quota-providers": "Quota providers", @@ -98,6 +100,8 @@ export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly> = { "kimi-for-code": "kimi-for-coding", "kimi-code": "kimi-for-coding", "deep-seek": "deepseek", + grok: "xai", + "x-ai": "xai", "opencode-go-subscription": "opencode-go", "gemini-cli": "google-gemini-cli", "google-gemini": "google-gemini-cli", @@ -140,6 +144,7 @@ export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = { ], "kimi-for-coding": ["kimi-for-coding", "kimi", "kimi-code"], deepseek: ["deepseek"], + xai: ["xai", "grok"], "opencode-go": ["opencode-go"], "ollama-cloud": ["ollama-cloud"], "quota-providers": [], @@ -278,6 +283,13 @@ export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [ authFallbacks: ["env_api_key", "global_opencode_config"], quota: "remote_api", }, + { + id: "xai", + autoSetup: "yes", + authentication: "opencode_auth_oauth_token", + quota: "remote_api", + notes: "SuperGrok OAuth via OpenCode /connect; shared weekly credit meter", + }, { id: "opencode-go", autoSetup: "needs_quick_setup", diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index 6c441de..7876953 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -1207,6 +1207,16 @@ export async function buildQuotaStatusReport(params: { sections.push(alibabaCodingPlanLiveProbeSection); } + const xaiLiveProbeSection = createCompactLiveProbeOnlySection({ + id: "xai", + title: "xai:", + providerId: "xai", + probes: params.providerLiveProbes, + }); + if (xaiLiveProbeSection) { + sections.push(xaiLiveProbeSection); + } + async function appendMiniMaxSection(section: { id: string; title: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index 33c026a..78aac7c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -274,6 +274,14 @@ export interface OpenAIOAuthData { [key: string]: unknown; } +export interface XaiOAuthData { + type: string; + access?: string; + refresh?: string; + expires?: number; + [key: string]: unknown; +} + export interface GeminiCliOAuthAuthData { type: string; access?: string; @@ -418,6 +426,7 @@ export interface AuthData { "minimax-cn-coding-plan"?: MiniMaxAuthData; "kimi-code"?: KimiAuthData; kimi?: KimiAuthData; + xai?: XaiOAuthData; } // ============================================================================= diff --git a/src/lib/xai.ts b/src/lib/xai.ts new file mode 100644 index 0000000..a4f81da --- /dev/null +++ b/src/lib/xai.ts @@ -0,0 +1,190 @@ +/** + * xAI SuperGrok subscription quota fetcher. + * + * Uses OpenCode's read-only `xai` OAuth entry and queries the shared period + * meter exposed by Grok Build: + * GET https://cli-chat-proxy.grok.com/v1/billing?format=credits + * + * OpenCode remains the sole owner of OAuth refresh and auth.json persistence. + */ + +import type { AuthData, QuotaError } from "./types.js"; +import { sanitizeDisplaySnippet, sanitizeDisplayText } from "./display-sanitize.js"; +import { clampPercent } from "./format-utils.js"; +import { fetchWithTimeout } from "./http.js"; +import { readAuthFile, readAuthFileCached } from "./opencode-auth.js"; + +export const DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS = 5_000; + +const CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const USER_AGENT = "OpenCode-Quota-Toast/1.0"; + +export type XaiPeriodKind = "weekly" | "monthly" | "daily" | "period"; + +export interface XaiWindowValue { + percentRemaining: number; + resetTimeIso?: string; + kind: XaiPeriodKind; +} + +export type XaiResult = + | { + success: true; + label: "xAI SuperGrok"; + window: XaiWindowValue; + } + | QuotaError + | null; + +export type ResolvedXaiOAuth = + | { state: "none" } + | { + state: "configured"; + accessToken: string; + expiresAt?: number; + }; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function getNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function isoOrUndefined(value: unknown): string | undefined { + const raw = getNonEmptyString(value); + if (!raw) return undefined; + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function periodKindFromType(value: unknown): XaiPeriodKind { + const raw = getNonEmptyString(value)?.toUpperCase() ?? ""; + if (raw.includes("WEEK")) return "weekly"; + if (raw.includes("MONTH")) return "monthly"; + if (raw.includes("DAY")) return "daily"; + return "period"; +} + +export function periodKindLabel(kind: XaiPeriodKind): string { + switch (kind) { + case "weekly": + return "Weekly"; + case "monthly": + return "Monthly"; + case "daily": + return "Daily"; + default: + return "Period"; + } +} + +export function resolveXaiOAuth(auth: AuthData | null | undefined): ResolvedXaiOAuth { + const entry = auth?.xai; + if (!entry || entry.type !== "oauth") return { state: "none" }; + + const accessToken = typeof entry.access === "string" ? entry.access.trim() : ""; + if (!accessToken) return { state: "none" }; + + return { + state: "configured", + accessToken, + expiresAt: typeof entry.expires === "number" ? entry.expires : undefined, + }; +} + +export function hasXaiOAuth(auth: AuthData | null | undefined): boolean { + return resolveXaiOAuth(auth).state === "configured"; +} + +export async function hasXaiOAuthCached(params?: { maxAgeMs?: number }): Promise { + const auth = await readAuthFileCached({ + maxAgeMs: Math.max(0, params?.maxAgeMs ?? DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS), + }); + return hasXaiOAuth(auth); +} + +function parseCreditsWindow(payload: unknown): XaiWindowValue | null { + if (!isRecord(payload) || !isRecord(payload.config)) { + throw new Error("xAI credits response returned an unexpected response shape"); + } + + const config = payload.config; + const period = isRecord(config.currentPeriod) ? config.currentPeriod : null; + const hasUsage = Object.prototype.hasOwnProperty.call(config, "creditUsagePercent"); + const hasPeriod = Boolean( + getNonEmptyString(period?.type) || + getNonEmptyString(period?.start) || + getNonEmptyString(period?.end), + ); + if (!hasPeriod && !hasUsage) return null; + + if ( + hasUsage && + (typeof config.creditUsagePercent !== "number" || !Number.isFinite(config.creditUsagePercent)) + ) { + throw new Error("xAI credits response returned an invalid usage percentage"); + } + + // Protobuf JSON omits zero-valued fields, so an absent percentage with a + // current period means 0% used rather than missing quota. + const usedPercent = hasUsage ? (config.creditUsagePercent as number) : 0; + return { + percentRemaining: clampPercent(100 - usedPercent), + resetTimeIso: isoOrUndefined(period?.end) ?? isoOrUndefined(config.billingPeriodEnd), + kind: periodKindFromType(period?.type), + }; +} + +export async function queryXaiQuota( + options: { requestTimeoutMs?: number } = {}, +): Promise { + // OpenCode can replace this OAuth entry while servicing a model request. + // Read the file directly so the post-request quota toast never reuses an + // in-memory token snapshot from before that refresh. + const resolvedAuth = resolveXaiOAuth(await readAuthFile()); + if (resolvedAuth.state !== "configured") return null; + + if (resolvedAuth.expiresAt && resolvedAuth.expiresAt < Date.now()) { + return { + success: false, + error: "xAI OAuth token expired; use xAI in OpenCode to refresh it or reconnect xAI", + }; + } + + try { + const resp = await fetchWithTimeout( + CREDITS_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${resolvedAuth.accessToken}`, + Accept: "application/json", + "User-Agent": USER_AGENT, + "x-grok-client-surface": "grok-build", + "x-grok-client-version": "1.0.0", + }, + }, + options.requestTimeoutMs, + ); + + if (!resp.ok) { + const text = await resp.text(); + return { + success: false, + error: `xAI API error ${resp.status}: ${sanitizeDisplaySnippet(text, 120)}`, + }; + } + + const window = parseCreditsWindow(await resp.json()); + if (!window) return { success: false, error: "No weekly quota data" }; + + return { success: true, label: "xAI SuperGrok", window }; + } catch (err) { + return { + success: false, + error: sanitizeDisplayText(err instanceof Error ? err.message : String(err)), + }; + } +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index ff5dc93..835dc76 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -26,6 +26,7 @@ import { import { opencodeGoProvider } from "./opencode-go.js"; import { kimiCodeProvider } from "./kimi-code.js"; import { deepseekProvider } from "./deepseek.js"; +import { xaiProvider } from "./xai.js"; import { ollamaCloudProvider } from "./ollama-cloud.js"; import { quotaProvidersProvider } from "./quota-providers.js"; @@ -50,6 +51,7 @@ export function getProviders(): QuotaProvider[] { minimaxChinaCodingPlanProvider, kimiCodeProvider, deepseekProvider, + xaiProvider, opencodeGoProvider, ollamaCloudProvider, quotaProvidersProvider, diff --git a/src/providers/xai.ts b/src/providers/xai.ts new file mode 100644 index 0000000..f2b759a --- /dev/null +++ b/src/providers/xai.ts @@ -0,0 +1,70 @@ +/** + * xAI SuperGrok provider wrapper. + */ + +import type { + QuotaProvider, + QuotaProviderContext, + QuotaProviderMatchContext, + QuotaProviderResult, +} from "../lib/entries.js"; +import { + DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS, + hasXaiOAuthCached, + periodKindLabel, + queryXaiQuota, +} from "../lib/xai.js"; +import { isCanonicalProviderAvailable } from "../lib/provider-availability.js"; +import { modelProviderMatchesRuntimeId } from "../lib/provider-model-matching.js"; +import { normalizeQuotaProviderId } from "../lib/provider-metadata.js"; +import { + attemptedResult, + groupedPercentWindowEntries, + mapNullableProviderResult, +} from "./result-helpers.js"; + +export const xaiProvider: QuotaProvider = { + id: "xai", + + async isAvailable(ctx: QuotaProviderContext): Promise { + const providerAvailable = await isCanonicalProviderAvailable({ + ctx, + providerId: "xai", + fallbackOnError: false, + }); + if (providerAvailable) return hasXaiOAuthCached({ maxAgeMs: 0 }); + return hasXaiOAuthCached({ maxAgeMs: DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS }); + }, + + matchesCurrentModel(model: string, context?: QuotaProviderMatchContext): boolean { + if (context?.currentProviderID) { + return normalizeQuotaProviderId(context.currentProviderID) === "xai"; + } + return modelProviderMatchesRuntimeId(model, "xai"); + }, + + async fetch(ctx: QuotaProviderContext): Promise { + const result = await queryXaiQuota({ requestTimeoutMs: ctx.config?.requestTimeoutMs }); + + return mapNullableProviderResult(result, { + errorLabel: "xAI", + onSuccess: (result) => { + const period = periodKindLabel(result.window.kind); + return attemptedResult( + groupedPercentWindowEntries({ + group: result.label, + accounting: { + resultType: "quota", + acquisitionMethod: "remote_api", + ownership: "maintained", + authority: "provider_reported", + }, + windows: [{ window: result.window, suffix: period, label: `${period}:` }], + }), + [], + { singleWindowDisplayName: result.label }, + ); + }, + }); + }, +}; diff --git a/tests/helpers/provider-assertions.ts b/tests/helpers/provider-assertions.ts index dc5a38a..634df89 100644 --- a/tests/helpers/provider-assertions.ts +++ b/tests/helpers/provider-assertions.ts @@ -189,6 +189,14 @@ export const PROVIDER_ACCOUNTING_LEDGER: Record { authFallbacks: ["env_api_key", "global_opencode_config"], quota: "remote_api", }, + { + id: "xai", + autoSetup: "yes", + authentication: "opencode_auth_oauth_token", + quota: "remote_api", + notes: "SuperGrok OAuth via OpenCode /connect; shared weekly credit meter", + }, { id: "opencode-go", autoSetup: "needs_quick_setup", @@ -232,6 +239,7 @@ describe("provider-metadata", () => { "kimi-code", ]); expect(QUOTA_PROVIDER_RUNTIME_IDS.deepseek).toEqual(["deepseek"]); + expect(QUOTA_PROVIDER_RUNTIME_IDS.xai).toEqual(["xai", "grok"]); expect(QUOTA_PROVIDER_RUNTIME_IDS["quota-providers"]).toEqual([]); }); @@ -284,6 +292,7 @@ describe("provider-metadata", () => { ]); expect(getQuotaProviderRuntimeIds("kimi")).toEqual(["kimi-for-coding", "kimi", "kimi-code"]); expect(getQuotaProviderRuntimeIds("deep-seek")).toEqual(["deepseek"]); + expect(getQuotaProviderRuntimeIds("grok")).toEqual(["xai", "grok"]); expect(getQuotaProviderRuntimeIds("not-a-provider")).toEqual([]); }); @@ -329,6 +338,13 @@ describe("provider-metadata", () => { authFallbacks: ["env_api_key", "global_opencode_config"], quota: "remote_api", }); + expect(getQuotaProviderShape("grok")).toEqual({ + id: "xai", + autoSetup: "yes", + authentication: "opencode_auth_oauth_token", + quota: "remote_api", + notes: "SuperGrok OAuth via OpenCode /connect; shared weekly credit meter", + }); expect(getQuotaProviderShape("not-a-provider")).toBeUndefined(); }); @@ -350,6 +366,7 @@ describe("provider-metadata", () => { expect(getQuotaProviderDisplayLabel("kimi-code")).toBe("Kimi Code"); expect(getQuotaProviderDisplayLabel("kimi")).toBe("Kimi Code"); expect(getQuotaProviderDisplayLabel("deep-seek")).toBe("DeepSeek"); + expect(getQuotaProviderDisplayLabel("grok")).toBe("xAI"); expect(getQuotaProviderDisplayLabel("quota-providers")).toBe("Quota providers"); expect(getQuotaProviderDisplayLabel("something-else")).toBe("something-else"); }); diff --git a/tests/lib.quota-status.test.ts b/tests/lib.quota-status.test.ts index 0ede749..b017ffe 100644 --- a/tests/lib.quota-status.test.ts +++ b/tests/lib.quota-status.test.ts @@ -1394,6 +1394,40 @@ describe("buildQuotaStatusReport", () => { expect(report).toContain("- deepseek: pricing=no (account balance only (not token-priced))"); }); + it("reports the xAI live weekly quota probe", async () => { + const report = await buildProviderStatusReport("xai", { + providerLiveProbes: [ + { + providerId: "xai", + result: { + attempted: true, + entries: [ + { + accounting: { + resultType: "quota", + acquisitionMethod: "remote_api", + ownership: "maintained", + authority: "provider_reported", + }, + name: "xAI SuperGrok Weekly", + group: "xAI SuperGrok", + label: "Weekly:", + percentRemaining: 84, + resetTimeIso: "2026-07-20T02:24:00.983Z", + }, + ], + errors: [], + }, + }, + ], + }); + + expectReportSection(report, "xai:", [ + "- live_probe: success", + "- live_entry_1: Weekly: percent_remaining=84 reset_at=2026-07-20T02:24:00.983Z", + ]); + }); + it("reports OpenCode Go rolling, weekly, and monthly live usage when configured", async () => { openCodeGoMocks.getOpenCodeGoConfigDiagnostics.mockResolvedValueOnce({ state: "configured", diff --git a/tests/lib.xai.test.ts b/tests/lib.xai.test.ts new file mode 100644 index 0000000..2307f5c --- /dev/null +++ b/tests/lib.xai.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readAuthFile: vi.fn(), + readAuthFileCached: vi.fn(), +})); + +vi.mock("../src/lib/opencode-auth.js", () => ({ + readAuthFile: mocks.readAuthFile, + readAuthFileCached: mocks.readAuthFileCached, +})); + +import { hasXaiOAuth, periodKindLabel, queryXaiQuota, resolveXaiOAuth } from "../src/lib/xai.js"; + +describe("xai auth resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves a read-only OAuth access token", () => { + expect( + resolveXaiOAuth({ + xai: { type: "oauth", access: "token-1", refresh: "refresh-1", expires: 123 }, + }), + ).toEqual({ + state: "configured", + accessToken: "token-1", + expiresAt: 123, + }); + expect(hasXaiOAuth({ xai: { type: "api", key: "xai-key" } as any })).toBe(false); + expect(hasXaiOAuth({})).toBe(false); + }); + + it("returns null when OAuth is not configured", async () => { + mocks.readAuthFile.mockResolvedValueOnce({}); + + await expect(queryXaiQuota()).resolves.toBeNull(); + expect(mocks.readAuthFile).toHaveBeenCalledOnce(); + expect(mocks.readAuthFileCached).not.toHaveBeenCalled(); + }); + + it("does not refresh or write a still-expired token", async () => { + const expiredAuth = { + xai: { + type: "oauth", + access: "expired-token", + refresh: "refresh-1", + expires: Date.now() - 1_000, + }, + }; + mocks.readAuthFile.mockResolvedValueOnce(expiredAuth); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(queryXaiQuota()).resolves.toEqual({ + success: false, + error: "xAI OAuth token expired; use xAI in OpenCode to refresh it or reconnect xAI", + }); + expect(mocks.readAuthFile).toHaveBeenCalledOnce(); + expect(mocks.readAuthFileCached).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("bypasses a stale cached token before querying quota", async () => { + mocks.readAuthFileCached.mockResolvedValueOnce({ + xai: { type: "oauth", access: "expired-token", expires: Date.now() - 1_000 }, + }); + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "fresh-token", expires: Date.now() + 60_000 }, + }); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + end: "2026-07-20T02:24:00.983423+00:00", + }, + creditUsagePercent: 5, + }, + }), + { status: 200 }, + ), + ) as any; + vi.stubGlobal("fetch", fetchMock); + + await expect(queryXaiQuota()).resolves.toMatchObject({ + success: true, + label: "xAI SuperGrok", + window: { percentRemaining: 95, kind: "weekly" }, + }); + expect(mocks.readAuthFile).toHaveBeenCalledOnce(); + expect(mocks.readAuthFileCached).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer fresh-token" }), + }), + ); + }); + + it("maps the shared weekly meter with one authenticated request", async () => { + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "token-1", expires: Date.now() + 60_000 }, + }); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-13T02:24:00.983423+00:00", + end: "2026-07-20T02:24:00.983423+00:00", + }, + creditUsagePercent: 5, + isUnifiedBillingUser: true, + productUsage: [{ product: "Api", usagePercent: 5 }, { product: "GrokChat" }], + }, + }), + { status: 200 }, + ), + ) as any; + vi.stubGlobal("fetch", fetchMock); + + await expect(queryXaiQuota({ requestTimeoutMs: 3210 })).resolves.toEqual({ + success: true, + label: "xAI SuperGrok", + window: { + percentRemaining: 95, + resetTimeIso: "2026-07-20T02:24:00.983Z", + kind: "weekly", + }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer token-1", + "x-grok-client-surface": "grok-build", + }), + signal: expect.any(AbortSignal), + }), + ); + }); + + it("treats an omitted protobuf percentage as 0% used when a period exists", async () => { + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "token-1", expires: Date.now() + 60_000 }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + end: "2026-07-20T02:24:00.983423+00:00", + }, + }, + }), + { status: 200 }, + ), + ) as any, + ); + + const out = await queryXaiQuota(); + expect(out && out.success ? out.window.percentRemaining : null).toBe(100); + }); + + it("rejects a present malformed percentage", async () => { + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "token-1", expires: Date.now() + 60_000 }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + config: { + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY" }, + creditUsagePercent: "100", + }, + }), + { status: 200 }, + ), + ) as any, + ); + + await expect(queryXaiQuota()).resolves.toEqual({ + success: false, + error: "xAI credits response returned an invalid usage percentage", + }); + }); + + it("reports missing period data instead of synthesizing 0% remaining", async () => { + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "token-1", expires: Date.now() + 60_000 }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ config: { currentPeriod: {} } }), { status: 200 }), + ) as any, + ); + + await expect(queryXaiQuota()).resolves.toEqual({ + success: false, + error: "No weekly quota data", + }); + }); + + it("returns sanitized API errors", async () => { + mocks.readAuthFile.mockResolvedValueOnce({ + xai: { type: "oauth", access: "token-1", expires: Date.now() + 60_000 }, + }); + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 401 })) as any); + + await expect(queryXaiQuota()).resolves.toEqual({ + success: false, + error: "xAI API error 401: nope", + }); + }); +}); + +describe("periodKindLabel", () => { + it("labels supported period kinds", () => { + expect(periodKindLabel("weekly")).toBe("Weekly"); + expect(periodKindLabel("monthly")).toBe("Monthly"); + expect(periodKindLabel("daily")).toBe("Daily"); + expect(periodKindLabel("period")).toBe("Period"); + }); +}); diff --git a/tests/providers.xai.test.ts b/tests/providers.xai.test.ts new file mode 100644 index 0000000..970925e --- /dev/null +++ b/tests/providers.xai.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + expectAttemptedWithErrorLabel, + expectAttemptedWithNoErrors, + expectNotAttempted, + visibleEntries, +} from "./helpers/provider-assertions.js"; +import { createProviderAvailabilityContext } from "./helpers/provider-test-harness.js"; +import { xaiProvider } from "../src/providers/xai.js"; + +const xaiMocks = vi.hoisted(() => ({ + hasXaiOAuthCached: vi.fn(), + periodKindLabel: vi.fn((kind: string) => (kind === "weekly" ? "Weekly" : "Period")), + queryXaiQuota: vi.fn(), +})); + +vi.mock("../src/lib/xai.js", () => ({ + DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS: 5_000, + hasXaiOAuthCached: xaiMocks.hasXaiOAuthCached, + periodKindLabel: xaiMocks.periodKindLabel, + queryXaiQuota: xaiMocks.queryXaiQuota, +})); + +describe("xai provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + xaiMocks.hasXaiOAuthCached.mockResolvedValue(true); + }); + + it("passes configured requestTimeoutMs to the query", async () => { + xaiMocks.queryXaiQuota.mockResolvedValueOnce(null); + + await xaiProvider.fetch({ config: { requestTimeoutMs: 12000 } } as any); + + expect(xaiMocks.queryXaiQuota).toHaveBeenCalledWith({ requestTimeoutMs: 12000 }); + }); + + it("returns attempted:false when OAuth is not configured", async () => { + xaiMocks.queryXaiQuota.mockResolvedValueOnce(null); + + const out = await xaiProvider.fetch({} as any); + + expectNotAttempted(out); + }); + + it("maps one shared weekly row with maintained remote accounting", async () => { + xaiMocks.queryXaiQuota.mockResolvedValueOnce({ + success: true, + label: "xAI SuperGrok", + window: { + percentRemaining: 95, + resetTimeIso: "2026-07-20T02:24:00.983Z", + kind: "weekly", + }, + }); + + const out = await xaiProvider.fetch({} as any); + + expectAttemptedWithNoErrors(out); + expect(visibleEntries(out.entries, "xai")).toEqual([ + { + name: "xAI SuperGrok Weekly", + group: "xAI SuperGrok", + label: "Weekly:", + percentRemaining: 95, + resetTimeIso: "2026-07-20T02:24:00.983Z", + }, + ]); + expect(out.presentation).toEqual({ singleWindowDisplayName: "xAI SuperGrok" }); + }); + + it("maps errors into toast errors", async () => { + xaiMocks.queryXaiQuota.mockResolvedValueOnce({ + success: false, + error: "Token expired", + }); + + const out = await xaiProvider.fetch({} as any); + + expectAttemptedWithErrorLabel(out, "xAI"); + }); + + it("uses currentProviderID to distinguish direct xAI from Copilot Grok", () => { + expect( + xaiProvider.matchesCurrentModel?.("grok-4", { + enabledProviders: "auto", + currentProviderID: "xai", + }), + ).toBe(true); + expect( + xaiProvider.matchesCurrentModel?.("grok-code-fast-1", { + enabledProviders: "auto", + currentProviderID: "github-copilot", + }), + ).toBe(false); + expect(xaiProvider.matchesCurrentModel?.("xai/grok-4", { enabledProviders: "auto" })).toBe( + true, + ); + }); + + it("requires both the xAI runtime provider and OAuth, or OAuth fallback", async () => { + xaiMocks.hasXaiOAuthCached.mockResolvedValueOnce(false); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: ["xai"] })), + ).resolves.toBe(false); + + xaiMocks.hasXaiOAuthCached.mockResolvedValueOnce(true); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: ["xai"] })), + ).resolves.toBe(true); + expect(xaiMocks.hasXaiOAuthCached).toHaveBeenLastCalledWith({ maxAgeMs: 0 }); + + xaiMocks.hasXaiOAuthCached.mockResolvedValueOnce(true); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: [] })), + ).resolves.toBe(true); + expect(xaiMocks.hasXaiOAuthCached).toHaveBeenLastCalledWith({ maxAgeMs: 5_000 }); + }); +});