feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex) Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback - restore quota trigger, guard NaN date - Re-add triggerFetch() on agent idle/error in agent status subscription - Guard formatResetsAtLabel against NaN from malformed API date strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve composer conflict and stub subscribe in websocket tests * feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety * feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics * security(quota): fix shell injection vulnerability by migrating exec to execFile * revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1 * Fix i18n resource test expectation * Fix quota tooltip empty-provider state * Preserve zero values in Grok quota * Fix empty quota fetch state * Ignore quota frames in relay reconnect tests * Persist refreshed Codex tokens to source auth file * fix(quota): read Claude OAuth credentials from the macOS login Keychain On macOS, Claude Code stores its OAuth credential in the login Keychain (generic-password item, service "Claude Code-credentials"), not in ~/.claude/.credentials.json — that file usually does not exist there, so the Claude provider's existsSync check failed and macOS users silently got no Claude quota. Read it via the macOS `security` CLI (its ACL only trusts /usr/bin/security to decrypt; a native Keychain read would prompt), and stay read-only there since Claude Code owns the refresh/persist. Linux and Windows keep using ~/.claude/.credentials.json unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix quota keychain timeout and local host reconciliation * Fix terminal notification test quota stub * Ignore quota frames in terminal notification tests * fix(quota): bypass Claude token refresh on macOS Keychain-backed logins * fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1 * fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1 * fix(client): handle and dispatch provider_quota event in DaemonClient * fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding * fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs * fix(desktop): fix concurrently command invocation on Windows in dev.ps1 * fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows * Add on-demand provider usage views * Fix Cursor usage billing dates * Move provider usage renderer coverage to e2e * Use provider manifest for quota fetchers * Add provider usage fetch timeouts * Reshape provider usage fetchers --------- Co-authored-by: ABorakati <ABorakati@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
co-authored by
ABorakati
Claude Sonnet 4.6
Mohamed Boudra
lumingjun
parent
2b0740ff84
commit
acea7f3d24
@@ -0,0 +1,273 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageWindow,
|
||||
} from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import {
|
||||
ApiNumberSchema,
|
||||
balanceToneFromRemaining,
|
||||
fetchProviderApi,
|
||||
unavailableUsage,
|
||||
windowFromUsedPct,
|
||||
} from "../usage.js";
|
||||
|
||||
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
|
||||
const CodexAuthSchema = z.object({
|
||||
tokens: z
|
||||
.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
account_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const CodexWindowSchema = z.object({
|
||||
used_percent: ApiNumberSchema.optional(),
|
||||
reset_at: ApiNumberSchema.optional(),
|
||||
});
|
||||
|
||||
const CodexUsageResponseSchema = z.object({
|
||||
plan_type: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
secondary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
code_review_rate_limit: z
|
||||
.object({
|
||||
primary_window: CodexWindowSchema.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
credits: z
|
||||
.object({
|
||||
has_credits: z.boolean().optional(),
|
||||
unlimited: z.boolean().optional(),
|
||||
balance: ApiNumberSchema.optional(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const CodexTokenRefreshSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
type CodexAuth = z.infer<typeof CodexAuthSchema>;
|
||||
type CodexWindow = z.infer<typeof CodexWindowSchema>;
|
||||
type CodexUsageResponse = z.infer<typeof CodexUsageResponseSchema>;
|
||||
type CodexTokenRefresh = z.infer<typeof CodexTokenRefreshSchema>;
|
||||
|
||||
interface CodexAuthRecord {
|
||||
auth: CodexAuth;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CodexQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
codexHome?: string;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
function codexWindow(
|
||||
window: CodexWindow | null | undefined,
|
||||
): { usedPct: number; resetsAt: string | null } | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
usedPct: window.used_percent ?? 0,
|
||||
resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodexQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "codex";
|
||||
readonly displayName = "Codex";
|
||||
|
||||
private readonly codexHome: string;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: CodexQuotaProviderOptions) {
|
||||
this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex");
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const authRecord = await this.readCodexAuth();
|
||||
const auth = authRecord?.auth;
|
||||
const accessToken = auth?.tokens?.access_token;
|
||||
if (!authRecord || !auth || !accessToken) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const { refresh_token, account_id } = auth.tokens ?? {};
|
||||
let resp = await this.callCodexApi(accessToken, account_id);
|
||||
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
if (!refresh_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
const refreshed = await this.refreshCodexToken(refresh_token);
|
||||
if (!refreshed?.access_token) {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
await this.saveCodexAuth(authRecord.path, auth, refreshed);
|
||||
resp = await this.callCodexApi(refreshed.access_token, account_id);
|
||||
if (resp === "NEEDS_AUTH") {
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
}
|
||||
|
||||
return this.toUsage(resp);
|
||||
}
|
||||
|
||||
private toUsage(resp: CodexUsageResponse): ProviderUsage {
|
||||
const session = codexWindow(resp.rate_limit?.primary_window);
|
||||
const weekly = codexWindow(resp.rate_limit?.secondary_window);
|
||||
const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window);
|
||||
const windows: ProviderUsageWindow[] = [];
|
||||
|
||||
if (session) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "session",
|
||||
label: "Session",
|
||||
utilizationPct: session.usedPct,
|
||||
resetsAt: session.resetsAt,
|
||||
tone: "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (weekly) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
utilizationPct: weekly.usedPct,
|
||||
resetsAt: weekly.resetsAt,
|
||||
tone: weekly.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (codeReview) {
|
||||
windows.push(
|
||||
windowFromUsedPct({
|
||||
id: "code_review",
|
||||
label: "Code review",
|
||||
utilizationPct: codeReview.usedPct,
|
||||
resetsAt: codeReview.resetsAt,
|
||||
tone: codeReview.usedPct >= 70 ? "warning" : "ok",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (resp.credits?.balance !== undefined) {
|
||||
balances.push({
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: resp.credits.balance,
|
||||
unit: "usd",
|
||||
tone: balanceToneFromRemaining(resp.credits.balance),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: resp.plan_type ?? null,
|
||||
windows,
|
||||
balances,
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readCodexAuth(): Promise<CodexAuthRecord | null> {
|
||||
const candidates = [
|
||||
...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []),
|
||||
join(homedir(), ".config", "codex", "auth.json"),
|
||||
join(this.codexHome, "auth.json"),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
if (auth.tokens?.access_token) return { auth, path };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async callCodexApi(
|
||||
token: string,
|
||||
accountId?: string,
|
||||
): Promise<CodexUsageResponse | "NEEDS_AUTH"> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
};
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
|
||||
const res = await fetchProviderApi(
|
||||
this.fetchApi,
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (res.status === 401 || res.status === 403) return "NEEDS_AUTH";
|
||||
if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (text.trim().startsWith("<")) return "NEEDS_AUTH";
|
||||
return CodexUsageResponseSchema.parse(JSON.parse(text));
|
||||
}
|
||||
|
||||
private async refreshCodexToken(refreshToken: string): Promise<CodexTokenRefresh | null> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return CodexTokenRefreshSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
private async saveCodexAuth(
|
||||
authPath: string,
|
||||
original: CodexAuth,
|
||||
refreshed: CodexTokenRefresh,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const updated: CodexAuth = {
|
||||
...original,
|
||||
tokens: {
|
||||
...original.tokens,
|
||||
access_token: refreshed.access_token ?? original.tokens?.access_token,
|
||||
refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
} catch {
|
||||
// Non-fatal; the next call can refresh again.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user