diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index aa929f43..5421b6cc 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -50,6 +50,13 @@ interface PendingRequest { timer: NodeJS.Timeout; } +interface GrokCredential { + token: string; + authMode: number; + expiresAt: number; + sessionExpired: boolean; +} + export class LimitsService { private codex: CodexAppServerClient; private kimi: KimiWebUsageClient; @@ -402,10 +409,11 @@ async function readOpenCodeGoUsage(clientVersion: string): Promise { async function readGrokUsage(clientVersion: string): Promise { const configRoot = process.env.GROK_HOME || join(homedir(), ".grok"); const credentials = await readCredentialFile(join(configRoot, "auth.json"), "not-authenticated"); - const accessToken = selectGrokAccessToken(credentials); - if (!accessToken) throw new LimitsAdapterError("not-authenticated"); + const credential = selectGrokCredential(credentials); + if (!credential) throw new LimitsAdapterError("not-authenticated"); + if (credential.sessionExpired) throw new LimitsAdapterError("session-expired"); - return fetchUsageJson(GROK_BILLING_URL, accessToken, { + return fetchUsageJson(GROK_BILLING_URL, credential.token, { "x-xai-token-auth": "xai-grok-cli", "user-agent": `canvastty/${clientVersion}` }); @@ -445,17 +453,18 @@ async function readFirstCredentialFile( throw new LimitsAdapterError(missingReason); } -function selectGrokAccessToken(credentials: Record): string | null { +function selectGrokCredential(credentials: Record): GrokCredential | null { const candidates = Object.values(credentials) .filter(isRecord) .map((credential) => ({ token: cleanSecret(credential.key), authMode: credential.auth_mode === "oidc" ? 1 : 0, - expiresAt: numericValue(credential.expires_at) ?? 0 + expiresAt: numericValue(credential.expires_at) ?? 0, + sessionExpired: cleanSecret(credential.refresh_token) !== null && hasExpired(credential.expires_at) })) - .filter((candidate): candidate is { token: string; authMode: number; expiresAt: number } => candidate.token !== null) + .filter((candidate): candidate is GrokCredential => candidate.token !== null) .sort((left, right) => right.authMode - left.authMode || right.expiresAt - left.expiresAt); - return candidates[0]?.token ?? null; + return candidates[0] ?? null; } async function readCredentialFile(path: string, missingReason: LimitUnavailableReason): Promise> { @@ -1199,6 +1208,11 @@ function epochMilliseconds(value: unknown): number | null { return Number.isSafeInteger(Math.trunc(milliseconds)) ? Math.trunc(milliseconds) : null; } +function hasExpired(value: unknown): boolean { + const expiresAt = epochMilliseconds(value); + return expiresAt !== null && expiresAt <= Date.now(); +} + function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index 36d9a202..8167b50f 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -583,6 +583,8 @@ function limitReasonLabel(reason: HomeLimitReason | null, locale: LocaleId): str ? "limitCliNotFound" : reason === "not-authenticated" ? "limitNotAuthenticated" + : reason === "session-expired" + ? "limitSessionExpired" : reason === "subscription-required" ? "limitSubscriptionRequired" : reason === "unsupported-protocol" diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 0495f7be..4544c1b9 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -24,6 +24,7 @@ const ru = { limitUnavailable: "Данные недоступны", limitCliNotFound: "CLI не найден", limitNotAuthenticated: "Требуется вход", + limitSessionExpired: "Сессия истекла, запустите CLI", limitSubscriptionRequired: "Требуется подписка", limitUnsupported: "Лимит не поддерживается", limitTimeout: "Провайдер не ответил", @@ -628,6 +629,7 @@ const en: Record = { limitUnavailable: "Data unavailable", limitCliNotFound: "CLI not found", limitNotAuthenticated: "Sign-in required", + limitSessionExpired: "Session expired, launch the CLI", limitSubscriptionRequired: "Subscription required", limitUnsupported: "Limit not supported", limitTimeout: "Provider timed out", diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 40279629..cbafab34 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -999,6 +999,7 @@ export type LimitSource = export type LimitUnavailableReason = | "cli-not-found" | "not-authenticated" + | "session-expired" | "subscription-required" | "unsupported-protocol" | "timeout" diff --git a/tests/grok-limits-adapter.test.mjs b/tests/grok-limits-adapter.test.mjs new file mode 100644 index 00000000..0a65a84d --- /dev/null +++ b/tests/grok-limits-adapter.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LimitsService } from "../src/main/services/LimitsService.ts"; + +async function readGrokLimits(credentials) { + const grokHome = await mkdtemp(join(tmpdir(), "canvastty-grok-limits-")); + const originalFetch = globalThis.fetch; + const previousGrokHome = process.env.GROK_HOME; + const requests = []; + const service = new LimitsService({ + get(provider) { + if (provider === "grok") { + return { state: "available", provider, executable: "/resolved/grok", launcher: "native", environment: {}, checked: [] }; + } + return { state: "unavailable", provider, reason: "cli-not-found", checked: [], diagnostic: "" }; + } + }, "test"); + try { + await writeFile(join(grokHome, "auth.json"), JSON.stringify(credentials), "utf8"); + process.env.GROK_HOME = grokHome; + globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), authorization: init.headers.authorization }); + if (init.headers.authorization !== "Bearer live-token") return new Response("{}", { status: 401 }); + return Response.json({ + config: { + creditUsagePercent: 43, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: "2026-09-21T06:16:11Z", end: "2026-09-28T06:16:11Z" } + } + }); + }; + const grok = (await service.get()).providers.find(({ provider }) => provider === "grok"); + return { grok, requests }; + } finally { + service.dispose(); + globalThis.fetch = originalFetch; + if (previousGrokHome === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = previousGrokHome; + await rm(grokHome, { recursive: true, force: true }); + } +} + +test("expired Grok sessions ask to launch the CLI without a billing request", async () => { + const { grok, requests } = await readGrokLimits({ + "https://auth.x.ai::client": { + key: "expired-token", + auth_mode: "oidc", + refresh_token: "refresh-token", + expires_at: "2026-09-21T08:58:02.037226Z" + } + }); + + assert.equal(grok.state, "unavailable"); + assert.equal(grok.reason, "session-expired"); + assert.deepEqual(requests, []); +}); + +test("expired Grok tokens without a refresh token still require sign-in", async () => { + const { grok, requests } = await readGrokLimits({ + "https://accounts.x.ai/sign-in": { key: "expired-token", refresh_token: " ", expires_at: "2026-09-21T08:58:02Z" } + }); + + assert.equal(grok.state, "unavailable"); + assert.equal(grok.reason, "not-authenticated"); + assert.equal(requests.length, 1); +}); + +test("a stale Grok credential does not hide the selected live one", async () => { + const { grok, requests } = await readGrokLimits({ + "https://accounts.x.ai/sign-in": { key: "expired-token", refresh_token: "refresh-token", expires_at: "2026-09-21T08:58:02Z" }, + "https://auth.x.ai::client": { + key: "live-token", + auth_mode: "oidc", + refresh_token: "refresh-token", + expires_at: new Date(Date.now() + 3_600_000).toISOString() + } + }); + + assert.equal(grok.state, "available"); + assert.equal(requests.length, 1); +}); + +test("Grok credentials without an expiry still read billing", async () => { + const { grok, requests } = await readGrokLimits({ + "https://auth.x.ai::client": { key: "live-token", auth_mode: "oidc", refresh_token: "refresh-token" } + }); + + assert.equal(grok.state, "available"); + assert.equal(requests.length, 1); +}); + +test("live Grok sessions still read billing", async () => { + const { grok, requests } = await readGrokLimits({ + "https://auth.x.ai::client": { + key: "live-token", + auth_mode: "oidc", + refresh_token: "refresh-token", + expires_at: new Date(Date.now() + 3_600_000).toISOString() + } + }); + + assert.equal(grok.state, "available"); + assert.equal(grok.windows[0].usedPercent, 43); + assert.deepEqual(requests, [{ + url: "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + authorization: "Bearer live-token" + }]); +});