A usage bar in Settings -> Usage stayed green all the way to 100%, so the one moment the meter matters was the moment it said nothing. The client already computes this. `window-bar.tsx` reads `window.tone ?? deriveTone(usedPct)`, and `deriveTone` escalates past 70% and 90%. But `usage.ts` only assigns `window.tone` when a provider sends one, so any provider that sends a tone opts out of the thresholds entirely. Claude and Kimi hardcoded `tone: "ok"`, and Codex escalated to `warning` past 70% but could never reach `danger`. Providers now derive tone from the percentage they already have, through one shared `toneFromUsedPct` whose thresholds match the client's. Claude, Codex and Kimi windows all escalate correctly. Balances had the same gap by a different route: `balance-bar.tsx` has no `deriveTone` fallback, and `balanceToneFromRemaining` only escalates once a balance is completely spent, so a credits bar at 99% was green. Cursor and Grok know their limit, so they now tone by used percentage. Codex credits report only a remaining balance with no limit, so they keep the remaining-based tone, documented as the no-limit case. MiniMax is unchanged: it maps a status its own API supplies rather than hardcoding a tone. Verified against the live Anthropic API with a session window at 83%, which now reports `warning` where it previously reported `ok`. Closes #2320 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
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,
|
|
toneFromUsedPct,
|
|
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: toneFromUsedPct(session.usedPct),
|
|
}),
|
|
);
|
|
}
|
|
if (weekly) {
|
|
windows.push(
|
|
windowFromUsedPct({
|
|
id: "weekly",
|
|
label: "Weekly",
|
|
utilizationPct: weekly.usedPct,
|
|
resetsAt: weekly.resetsAt,
|
|
tone: toneFromUsedPct(weekly.usedPct),
|
|
}),
|
|
);
|
|
}
|
|
if (codeReview) {
|
|
windows.push(
|
|
windowFromUsedPct({
|
|
id: "code_review",
|
|
label: "Code review",
|
|
utilizationPct: codeReview.usedPct,
|
|
resetsAt: codeReview.resetsAt,
|
|
tone: toneFromUsedPct(codeReview.usedPct),
|
|
}),
|
|
);
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
}
|