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:
@@ -21,6 +21,11 @@ const GrokUsageResponseSchema = z.object({
|
|||||||
val: ApiNumberSchema.optional(),
|
val: ApiNumberSchema.optional(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
|
used: z
|
||||||
|
.object({
|
||||||
|
val: ApiNumberSchema.optional(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
usage: z
|
usage: z
|
||||||
@@ -30,13 +35,36 @@ const GrokUsageResponseSchema = z.object({
|
|||||||
.nullish(),
|
.nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const GrokAuthSchema = z.object({
|
|
||||||
access_token: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface GrokQuotaProviderOptions {
|
interface GrokQuotaProviderOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
fetch?: ProviderApiFetch;
|
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 {
|
export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||||
@@ -45,10 +73,12 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
|||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly fetchApi: ProviderApiFetch;
|
private readonly fetchApi: ProviderApiFetch;
|
||||||
|
private readonly homeDir: string | undefined;
|
||||||
|
|
||||||
constructor(options: GrokQuotaProviderOptions) {
|
constructor(options: GrokQuotaProviderOptions) {
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.fetchApi = options.fetch ?? fetch;
|
this.fetchApi = options.fetch ?? fetch;
|
||||||
|
this.homeDir = options.homeDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchUsage(): Promise<ProviderUsage> {
|
async fetchUsage(): Promise<ProviderUsage> {
|
||||||
@@ -76,7 +106,8 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
|||||||
|
|
||||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||||
const monthlyLimit = resp.config?.monthlyLimit?.val ?? null;
|
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[] = [];
|
const balances: ProviderUsageBalance[] = [];
|
||||||
if (monthlyLimit !== null || creditUsage !== null) {
|
if (monthlyLimit !== null || creditUsage !== null) {
|
||||||
const remaining =
|
const remaining =
|
||||||
@@ -107,11 +138,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readGrokToken(): Promise<string | null> {
|
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;
|
if (!existsSync(path)) return null;
|
||||||
try {
|
try {
|
||||||
const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8")));
|
||||||
return auth.access_token ?? null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
function writeMiniMaxConfig(dir: string, payload: Record<string, unknown>): void {
|
||||||
mkdirSync(join(dir, ".mmx"), { recursive: true });
|
mkdirSync(join(dir, ".mmx"), { recursive: true });
|
||||||
writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload));
|
writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload));
|
||||||
@@ -382,7 +387,13 @@ describe("real provider usage fetchers", () => {
|
|||||||
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
|
||||||
new ZaiQuotaProvider({ 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({
|
new KimiQuotaProvider({
|
||||||
logger,
|
logger,
|
||||||
fetch: fetchThroughTestDouble,
|
fetch: fetchThroughTestDouble,
|
||||||
@@ -720,8 +731,7 @@ describe("real provider usage fetchers", () => {
|
|||||||
"https://cli-chat-proxy.grok.com/v1/billing",
|
"https://cli-chat-proxy.grok.com/v1/billing",
|
||||||
() =>
|
() =>
|
||||||
jsonResponse({
|
jsonResponse({
|
||||||
config: { monthlyLimit: { val: 0 } },
|
config: { monthlyLimit: { val: 0 }, used: { val: 0 } },
|
||||||
usage: { creditUsage: 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 () => {
|
it("fetches Kimi usage from KIMI_TOKEN", async () => {
|
||||||
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
process.env["KIMI_TOKEN"] = "kimi_test_token";
|
||||||
fetchApi = mockFetch(
|
fetchApi = mockFetch(
|
||||||
|
|||||||
Reference in New Issue
Block a user