fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353)

* 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).
This commit is contained in:
Jason@HND
2026-07-28 19:49:21 +09:00
committed by GitHub
parent f91a984348
commit f0d7eeb98c
2 changed files with 155 additions and 11 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>)["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<ProviderUsage> {
@@ -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<string | null> {
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;
}

View File

@@ -51,6 +51,11 @@ function writeKimiCredentials(dir: string, accessToken: string): void {
);
}
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
}
function writeMiniMaxConfig(dir: string, payload: Record<string, unknown>): 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<string, string> | 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(