Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/main/services/LimitsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -402,10 +409,11 @@ async function readOpenCodeGoUsage(clientVersion: string): Promise<unknown> {
async function readGrokUsage(clientVersion: string): Promise<unknown> {
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}`
});
Expand Down Expand Up @@ -445,17 +453,18 @@ async function readFirstCredentialFile(
throw new LimitsAdapterError(missingReason);
}

function selectGrokAccessToken(credentials: Record<string, unknown>): string | null {
function selectGrokCredential(credentials: Record<string, unknown>): 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<Record<string, unknown>> {
Expand Down Expand Up @@ -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));
}
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/features/home/HomeZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ru = {
limitUnavailable: "Данные недоступны",
limitCliNotFound: "CLI не найден",
limitNotAuthenticated: "Требуется вход",
limitSessionExpired: "Сессия истекла, запустите CLI",
limitSubscriptionRequired: "Требуется подписка",
limitUnsupported: "Лимит не поддерживается",
limitTimeout: "Провайдер не ответил",
Expand Down Expand Up @@ -628,6 +629,7 @@ const en: Record<keyof typeof ru, string> = {
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",
Expand Down
1 change: 1 addition & 0 deletions src/shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ export type LimitSource =
export type LimitUnavailableReason =
| "cli-not-found"
| "not-authenticated"
| "session-expired"
| "subscription-required"
| "unsupported-protocol"
| "timeout"
Expand Down
110 changes: 110 additions & 0 deletions tests/grok-limits-adapter.test.mjs
Original file line number Diff line number Diff line change
@@ -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"
}]);
});