diff --git a/README.md b/README.md index c111e34..778e6fa 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o | Zhipu Coding Plan | OpenCode config | Remote API | Quota | | NanoGPT | API key/config | Remote API | Quota and balance | | DeepSeek | API key/config | Remote API | Balance and status | +| xAI SuperGrok | OpenCode OAuth (`/connect` xAI) | Remote API | Weekly quota | | Ollama Cloud | [Needs setup](docs/readme/providers.md#ollama-cloud) | Dashboard scraping | Quota | | OpenCode Go | [Needs setup](docs/readme/providers.md#opencode-go) | Dashboard scraping | Quota | diff --git a/docs/readme/providers.md b/docs/readme/providers.md index eb5c05b..6cfc3b4 100644 --- a/docs/readme/providers.md +++ b/docs/readme/providers.md @@ -34,6 +34,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o | Zhipu Coding Plan | OpenCode config | Remote API | Quota | | NanoGPT | API key/config | Remote API | Quota and balance | | DeepSeek | API key/config | Remote API | Balance and status | +| xAI SuperGrok | OpenCode OAuth (`/connect` xAI) | Remote API | Weekly quota | | Ollama Cloud | [Needs setup](#ollama-cloud) | Dashboard scraping | Quota | | OpenCode Go | [Needs setup](#opencode-go) | Dashboard scraping | 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..178f5d6 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -1536,6 +1536,16 @@ export async function buildQuotaStatusReport(params: { appendProviderCompactLiveProbeRows(deepSeekRows, "deepseek", params.providerLiveProbes); sections.push(createKvSection("deepseek", "deepseek:", deepSeekRows)); + const xaiLiveProbeSection = createCompactLiveProbeOnlySection({ + id: "xai", + title: "xai:", + providerId: "xai", + probes: params.providerLiveProbes, + }); + if (xaiLiveProbeSection) { + sections.push(xaiLiveProbeSection); + } + // === nanogpt === const nanoGptDiag = await readApiKeyDiagnosticsWithAuthPaths(getNanoGptKeyDiagnostics); const nanoGptRows: ReportKvRow[] = [ 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..2f6096b --- /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 same 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 { 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 { + const auth = await readAuthFileCached({ + maxAgeMs: DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS, + }); + const resolvedAuth = resolveXaiOAuth(auth); + 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..fb48d8f --- /dev/null +++ b/src/providers/xai.ts @@ -0,0 +1,65 @@ +/** + * 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, 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( + [ + { + name: `${result.label} ${period}`, + group: result.label, + label: `${period}:`, + percentRemaining: result.window.percentRemaining, + resetTimeIso: result.window.resetTimeIso, + }, + ], + [], + { singleWindowDisplayName: result.label }, + ); + }, + }); + }, +}; diff --git a/tests/lib.provider-metadata.test.ts b/tests/lib.provider-metadata.test.ts index b855a65..fac4110 100644 --- a/tests/lib.provider-metadata.test.ts +++ b/tests/lib.provider-metadata.test.ts @@ -139,6 +139,13 @@ describe("provider-metadata", () => { 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", @@ -233,6 +240,7 @@ describe("provider-metadata", () => { ]); expect(QUOTA_PROVIDER_RUNTIME_IDS.deepseek).toEqual(["deepseek"]); expect(QUOTA_PROVIDER_RUNTIME_IDS["quota-providers"]).toEqual([]); + expect(QUOTA_PROVIDER_RUNTIME_IDS.xai).toEqual(["xai", "grok"]); }); it("keeps runtime ids distinct from broad normalization aliases", () => { @@ -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(); }); @@ -351,6 +367,8 @@ describe("provider-metadata", () => { expect(getQuotaProviderDisplayLabel("kimi")).toBe("Kimi Code"); expect(getQuotaProviderDisplayLabel("deep-seek")).toBe("DeepSeek"); expect(getQuotaProviderDisplayLabel("quota-providers")).toBe("Quota providers"); + expect(getQuotaProviderDisplayLabel("xai")).toBe("xAI"); + expect(getQuotaProviderDisplayLabel("grok")).toBe("xAI"); expect(getQuotaProviderDisplayLabel("something-else")).toBe("something-else"); }); }); diff --git a/tests/lib.xai.test.ts b/tests/lib.xai.test.ts new file mode 100644 index 0000000..21c6b13 --- /dev/null +++ b/tests/lib.xai.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { hasXaiOAuth, periodKindLabel, queryXaiQuota, resolveXaiOAuth } from "../src/lib/xai.js"; + +vi.mock("../src/lib/opencode-auth.js", () => ({ + readAuthFileCached: vi.fn(), +})); + +describe("xai auth resolution", () => { + 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); + }); +}); + +describe("queryXaiQuota", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns null when oauth is not configured", async () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).mockResolvedValueOnce({}); + + await expect(queryXaiQuota()).resolves.toBeNull(); + }); + + it("does not refresh or write an expired token", async () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).mockResolvedValueOnce({ + xai: { + type: "oauth", + access: "expired-token", + refresh: "refresh-1", + expires: Date.now() - 1_000, + }, + }); + 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(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps the shared weekly meter with one authenticated request", async () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).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 () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).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 () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).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 () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).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 () => { + const { readAuthFileCached } = await import("../src/lib/opencode-auth.js"); + (readAuthFileCached as any).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..82405f9 --- /dev/null +++ b/tests/providers.xai.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + expectAttemptedWithErrorLabel, + expectAttemptedWithNoErrors, + expectNotAttempted, +} from "./helpers/provider-assertions.js"; +import { createProviderAvailabilityContext } from "./helpers/provider-test-harness.js"; +import { xaiProvider } from "../src/providers/xai.js"; + +vi.mock("../src/lib/xai.js", () => ({ + DEFAULT_XAI_AUTH_CACHE_MAX_AGE_MS: 5_000, + hasXaiOAuthCached: vi.fn(), + periodKindLabel: vi.fn((kind: string) => (kind === "weekly" ? "Weekly" : "Period")), + queryXaiQuota: vi.fn(), +})); + +describe("xai provider", () => { + it("passes configured requestTimeoutMs to the query", async () => { + const { queryXaiQuota } = await import("../src/lib/xai.js"); + (queryXaiQuota as any).mockResolvedValueOnce(null); + + await xaiProvider.fetch({ config: { requestTimeoutMs: 12000 } } as any); + + expect(queryXaiQuota).toHaveBeenCalledWith({ requestTimeoutMs: 12000 }); + }); + + it("returns attempted:false when oauth is not configured", async () => { + const { queryXaiQuota } = await import("../src/lib/xai.js"); + (queryXaiQuota as any).mockResolvedValueOnce(null); + + const out = await xaiProvider.fetch({} as any); + expectNotAttempted(out); + }); + + it("maps exactly one shared weekly row", async () => { + const { queryXaiQuota } = await import("../src/lib/xai.js"); + (queryXaiQuota as any).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(out.entries).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 () => { + const { queryXaiQuota } = await import("../src/lib/xai.js"); + (queryXaiQuota as any).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 () => { + const { hasXaiOAuthCached } = await import("../src/lib/xai.js"); + + (hasXaiOAuthCached as any).mockResolvedValue(false); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: ["xai"] })), + ).resolves.toBe(false); + + (hasXaiOAuthCached as any).mockResolvedValue(true); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: ["xai"] })), + ).resolves.toBe(true); + await expect( + xaiProvider.isAvailable(createProviderAvailabilityContext({ providerIds: [] })), + ).resolves.toBe(true); + }); +});