+38
-25
@@ -11,11 +11,15 @@ const TIMEOUT_MS = 5_000;
|
||||
const RETRY_MS = 2_000;
|
||||
const RETRIES = 4;
|
||||
const WEEK = 604_800;
|
||||
const jsonValue = z.json();
|
||||
|
||||
type QuotaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export function claudeCredentialsPath() {
|
||||
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), ".credentials.json");
|
||||
function claudeCredentialsPath() {
|
||||
return join(
|
||||
process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"),
|
||||
".credentials.json",
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = z.object({
|
||||
@@ -27,7 +31,7 @@ const credentials = z.object({
|
||||
});
|
||||
|
||||
const usageWindow = z
|
||||
.object({ utilization: z.number().finite(), resets_at: z.string().nullish() })
|
||||
.object({ utilization: z.number(), resets_at: z.string().nullish() })
|
||||
.nullish();
|
||||
const usagePayload = z.object({
|
||||
five_hour: usageWindow,
|
||||
@@ -38,7 +42,7 @@ const usagePayload = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.string(),
|
||||
percent: z.number().finite(),
|
||||
percent: z.number(),
|
||||
resets_at: z.string().nullish(),
|
||||
scope: z
|
||||
.object({
|
||||
@@ -63,7 +67,7 @@ function window(
|
||||
label,
|
||||
seconds,
|
||||
used: Math.max(0, Math.min(100, used)),
|
||||
resetsAt: resetsAt ? Date.parse(resetsAt) : Number.NaN,
|
||||
resetsAt: resetsAt === null || resetsAt === undefined ? Number.NaN : Date.parse(resetsAt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,31 +177,14 @@ export class ClaudeQuotaTracker {
|
||||
const credential = parsed.data.claudeAiOauth;
|
||||
if (this.disposed) return true;
|
||||
if (credential.expiresAt <= Date.now()) return false;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetcher(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
const response = await this.fetchUsage(credential.accessToken);
|
||||
if (!response.ok) return false;
|
||||
// SAFETY: Response.json yields JSON values; usagePayload validates fields used.
|
||||
const payload = (await response.json()) as Schema.Json;
|
||||
const payload = jsonValue.parse(await response.json());
|
||||
const snapshot = normalizeClaudeQuota(
|
||||
payload,
|
||||
credential.subscriptionType ?? "unknown",
|
||||
);
|
||||
if (this.disposed) return true;
|
||||
if (this.isDisposed()) return true;
|
||||
if (!snapshot) return false;
|
||||
this.latest = snapshot;
|
||||
} catch {
|
||||
@@ -207,6 +194,32 @@ export class ClaudeQuotaTracker {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async fetchUsage(accessToken: string) {
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, TIMEOUT_MS);
|
||||
try {
|
||||
return await this.fetcher(USAGE_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (this.controller === controller) this.controller = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the flag through a call so checks after `await` are not narrowed away. */
|
||||
private isDisposed() {
|
||||
return this.disposed;
|
||||
}
|
||||
|
||||
status(): QuotaStatus | undefined {
|
||||
if (!this.latest) return;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user