From f0d7eeb98cb0ed3f940502b42fde6ba2e5eca242 Mon Sep 17 00:00:00 2001 From: "Jason@HND" Date: Tue, 28 Jul 2026 19:49:21 +0900 Subject: [PATCH] fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quota): restore Grok Settings usage for current CLI auth/billing Grok CLI no longer stores a top-level access_token or usage.creditUsage. Read nested auth key tokens and config.used.val so Settings → Usage shows monthly credits again. Keep legacy shapes and env tokens working. Fixes #2352 * fix(quota): make Grok auth-file tests work on Windows Inject homeDir into GrokQuotaProvider like Kimi so nested ~/.grok/auth.json tests do not depend on os.homedir() which ignores $HOME on Windows (USERPROFILE). --- .../services/quota-fetcher/providers/grok.ts | 47 +++++-- .../services/quota-fetcher/service.test.ts | 119 +++++++++++++++++- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts index 8d660c34c..33de944b7 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -21,6 +21,11 @@ const GrokUsageResponseSchema = z.object({ val: ApiNumberSchema.optional(), }) .nullish(), + used: z + .object({ + val: ApiNumberSchema.optional(), + }) + .nullish(), }) .nullish(), usage: z @@ -30,13 +35,36 @@ const GrokUsageResponseSchema = z.object({ .nullish(), }); -const GrokAuthSchema = z.object({ - access_token: z.string().optional(), -}); - interface GrokQuotaProviderOptions { logger: Logger; fetch?: ProviderApiFetch; + /** Override home directory (tests). Production uses os.homedir(). */ + homeDir?: string; +} + +/** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */ +export function extractGrokTokenFromAuth(auth: unknown): string | null { + if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null; + const record = auth as Record; + + const topLevel = record["access_token"]; + if (typeof topLevel === "string" && topLevel.length > 0) { + return topLevel; + } + + const entries = Object.entries(record); + const preferred = entries.filter(([key]) => key.startsWith("https://auth.x.ai::")); + const candidates = preferred.length > 0 ? preferred : entries; + + for (const [, value] of candidates) { + if (value == null || typeof value !== "object" || Array.isArray(value)) continue; + const nestedKey = (value as Record)["key"]; + if (typeof nestedKey === "string" && nestedKey.length > 0) { + return nestedKey; + } + } + + return null; } export class GrokQuotaProvider implements ProviderUsageFetcher { @@ -45,10 +73,12 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { private readonly logger: Logger; private readonly fetchApi: ProviderApiFetch; + private readonly homeDir: string | undefined; constructor(options: GrokQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; + this.homeDir = options.homeDir; } async fetchUsage(): Promise { @@ -76,7 +106,8 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { const resp = GrokUsageResponseSchema.parse(await res.json()); const monthlyLimit = resp.config?.monthlyLimit?.val ?? null; - const creditUsage = resp.usage?.creditUsage ?? null; + // Live CLI billing uses config.used.val; older mocks used usage.creditUsage. + const creditUsage = resp.config?.used?.val ?? resp.usage?.creditUsage ?? null; const balances: ProviderUsageBalance[] = []; if (monthlyLimit !== null || creditUsage !== null) { const remaining = @@ -107,11 +138,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { } private async readGrokToken(): Promise { - const path = join(homedir(), ".grok", "auth.json"); + // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). + const path = join(this.homeDir ?? homedir(), ".grok", "auth.json"); if (!existsSync(path)) return null; try { - const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - return auth.access_token ?? null; + return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); } catch { return null; } diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 7b1044cef..5550be407 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -51,6 +51,11 @@ function writeKimiCredentials(dir: string, accessToken: string): void { ); } +function writeGrokAuth(home: string, auth: Record): void { + mkdirSync(join(home, ".grok"), { recursive: true }); + writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); +} + function writeMiniMaxConfig(dir: string, payload: Record): void { mkdirSync(join(dir, ".mmx"), { recursive: true }); writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); @@ -382,7 +387,13 @@ describe("real provider usage fetchers", () => { new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new GrokQuotaProvider({ + logger, + fetch: fetchThroughTestDouble, + // Match Kimi: inject temp HOME so nested auth-file tests work on Windows + // (os.homedir() uses USERPROFILE there and ignores process.env.HOME). + homeDir, + }), new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble, @@ -720,8 +731,7 @@ describe("real provider usage fetchers", () => { "https://cli-chat-proxy.grok.com/v1/billing", () => jsonResponse({ - config: { monthlyLimit: { val: 0 } }, - usage: { creditUsage: 0 }, + config: { monthlyLimit: { val: 0 }, used: { val: 0 } }, }), ], ]), @@ -742,6 +752,109 @@ describe("real provider usage fetchers", () => { }); }); + it("fetches Grok usage from live billing shape (config.used.val)", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing", + () => + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 37886 }, + billingPeriodStart: "2026-07-01T00:00:00+00:00", + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 37886, + remaining: 112114, + limit: 150000, + unit: "credits", + }), + ], + }); + }); + + it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { + writeGrokAuth(homeDir, { + "https://auth.x.ai::test-user-id": { + key: "nested_jwt_token", + refresh_token: "rt_nested", + expires_at: "2026-08-01T00:00:00Z", + user_id: "test-user-id", + email: "user@example.com", + }, + }); + + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + config: { + monthlyLimit: { val: 100 }, + used: { val: 25 }, + }, + }); + }) as typeof fetch; + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(authorization).toBe("Bearer nested_jwt_token"); + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 25, + remaining: 75, + limit: 100, + }), + ], + }); + }); + + it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing", + () => + jsonResponse({ + config: { monthlyLimit: { val: 50 } }, + usage: { creditUsage: 10 }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 10, + remaining: 40, + limit: 50, + }), + ], + }); + }); + it("fetches Kimi usage from KIMI_TOKEN", async () => { process.env["KIMI_TOKEN"] = "kimi_test_token"; fetchApi = mockFetch(