mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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(),
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user