@juspay/neurolink 10.11.3 → 10.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +379 -379
- package/dist/cli/commands/auth.d.ts +8 -1
- package/dist/cli/commands/auth.js +185 -6
- package/dist/cli/factories/authCommandFactory.js +7 -1
- package/dist/lib/proxy/accountQuota.d.ts +6 -0
- package/dist/lib/proxy/accountQuota.js +19 -2
- package/dist/lib/proxy/accountUsage.d.ts +45 -0
- package/dist/lib/proxy/accountUsage.js +289 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +15 -1
- package/dist/lib/server/routes/claudeProxyRoutes.js +166 -0
- package/dist/lib/types/cli.d.ts +12 -0
- package/dist/lib/types/proxy.d.ts +101 -0
- package/dist/proxy/accountQuota.d.ts +6 -0
- package/dist/proxy/accountQuota.js +19 -2
- package/dist/proxy/accountUsage.d.ts +45 -0
- package/dist/proxy/accountUsage.js +288 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +15 -1
- package/dist/server/routes/claudeProxyRoutes.js +166 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/proxy.d.ts +101 -0
- package/package.json +3 -2
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Currently supports:
|
|
18
18
|
* - Anthropic (API key + OAuth)
|
|
19
19
|
*/
|
|
20
|
-
import type { AuthCommandArgs } from "../../lib/types/index.js";
|
|
20
|
+
import type { AccountQuota, AuthCommandArgs } from "../../lib/types/index.js";
|
|
21
21
|
/**
|
|
22
22
|
* Handle the login subcommand
|
|
23
23
|
* `neurolink auth login <provider>`
|
|
@@ -26,6 +26,13 @@ import type { AuthCommandArgs } from "../../lib/types/index.js";
|
|
|
26
26
|
* (e.g., "anthropic:alice") to support multi-account pools.
|
|
27
27
|
*/
|
|
28
28
|
export declare function handleLogin(argv: AuthCommandArgs): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Format the dynamic per-plan limit windows (model-scoped weeklies such as
|
|
31
|
+
* Fable, plus any future kinds) as extra display lines. `session` and
|
|
32
|
+
* `weekly_all` are omitted — they already render as the SESSION / WEEKLY
|
|
33
|
+
* columns. Exported for the continuous test suite.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatQuotaWindowRows(quota: AccountQuota): string[];
|
|
29
36
|
/**
|
|
30
37
|
* Handle the list subcommand
|
|
31
38
|
* `neurolink auth list`
|
|
@@ -27,7 +27,8 @@ import ora from "ora";
|
|
|
27
27
|
import { logger } from "../../lib/utils/logger.js";
|
|
28
28
|
import { defaultTokenStore } from "../../lib/auth/tokenStore.js";
|
|
29
29
|
import { CLAUDE_CODE_CLIENT_ID, ANTHROPIC_AUTH_URL, ANTHROPIC_TOKEN_URL, ANTHROPIC_REDIRECT_URI, CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../../lib/auth/anthropicOAuth.js";
|
|
30
|
-
import { loadAccountQuotas } from "../../lib/proxy/accountQuota.js";
|
|
30
|
+
import { flushAccountQuotas, loadAccountQuotas, saveAccountQuota, } from "../../lib/proxy/accountQuota.js";
|
|
31
|
+
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../lib/proxy/accountUsage.js";
|
|
31
32
|
// =============================================================================
|
|
32
33
|
// CONSTANTS
|
|
33
34
|
// =============================================================================
|
|
@@ -161,6 +162,137 @@ function formatQuotaColumns(quota) {
|
|
|
161
162
|
: "",
|
|
162
163
|
};
|
|
163
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Format the dynamic per-plan limit windows (model-scoped weeklies such as
|
|
167
|
+
* Fable, plus any future kinds) as extra display lines. `session` and
|
|
168
|
+
* `weekly_all` are omitted — they already render as the SESSION / WEEKLY
|
|
169
|
+
* columns. Exported for the continuous test suite.
|
|
170
|
+
*/
|
|
171
|
+
export function formatQuotaWindowRows(quota) {
|
|
172
|
+
if (!quota.windows?.length) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const colorize = (pct, text) => {
|
|
176
|
+
if (pct <= 10) {
|
|
177
|
+
return chalk.red(text);
|
|
178
|
+
}
|
|
179
|
+
if (pct <= 30) {
|
|
180
|
+
return chalk.yellow(text);
|
|
181
|
+
}
|
|
182
|
+
return chalk.green(text);
|
|
183
|
+
};
|
|
184
|
+
const rows = [];
|
|
185
|
+
for (const window of quota.windows) {
|
|
186
|
+
if (window.kind === "session" || window.kind === "weekly_all") {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const remaining = Math.round((1 - window.used) * 100);
|
|
190
|
+
const label = window.scopeModel
|
|
191
|
+
? `${window.group ?? window.kind} (${window.scopeModel})`
|
|
192
|
+
: window.kind;
|
|
193
|
+
const reset = window.resetsAt > 0
|
|
194
|
+
? chalk.gray(` resets ${formatTimeUntil(window.resetsAt)}`)
|
|
195
|
+
: "";
|
|
196
|
+
rows.push(`${chalk.gray(`${label}:`)} ${colorize(remaining, `${remaining}% left`)}${reset}`);
|
|
197
|
+
}
|
|
198
|
+
return rows;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Fetch fresh limits for `auth list --refresh`.
|
|
202
|
+
*
|
|
203
|
+
* Prefers the running proxy's GET /limits endpoint so the proxy's in-memory
|
|
204
|
+
* routing state is refreshed as a side effect; falls back to fetching the
|
|
205
|
+
* usage endpoint directly from this process (persisting through the same
|
|
206
|
+
* quota store) when no proxy is running or the call fails.
|
|
207
|
+
*/
|
|
208
|
+
async function refreshAccountLimitsForList() {
|
|
209
|
+
const errors = [];
|
|
210
|
+
const proxyState = detectRunningProxyState();
|
|
211
|
+
if (proxyState?.port) {
|
|
212
|
+
const host = proxyState.host && proxyState.host !== "0.0.0.0"
|
|
213
|
+
? proxyState.host
|
|
214
|
+
: "127.0.0.1";
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch(`http://${host}:${proxyState.port}/limits`, {
|
|
217
|
+
signal: AbortSignal.timeout(45_000),
|
|
218
|
+
});
|
|
219
|
+
if (response.ok) {
|
|
220
|
+
const payload = (await response.json());
|
|
221
|
+
const quotas = {};
|
|
222
|
+
for (const result of payload.results) {
|
|
223
|
+
if (result.quota) {
|
|
224
|
+
quotas[result.account] = result.quota;
|
|
225
|
+
}
|
|
226
|
+
if (result.status === "error" && result.error) {
|
|
227
|
+
errors.push(`${result.account}: ${result.error}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { via: "proxy", quotas, errors };
|
|
231
|
+
}
|
|
232
|
+
errors.push(`running proxy /limits returned HTTP ${response.status}; fetching directly`);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Keep the message generic: a raw fetch error can echo the requested
|
|
236
|
+
// URL, and this string reaches the text and JSON CLI output.
|
|
237
|
+
errors.push("running proxy /limits unreachable; fetching directly");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const accounts = await listAnthropicAccountsForUsage();
|
|
242
|
+
const prior = await loadAccountQuotas().catch(() => ({}));
|
|
243
|
+
const quotas = {};
|
|
244
|
+
const CONCURRENCY = 3;
|
|
245
|
+
let nextIndex = 0;
|
|
246
|
+
const worker = async () => {
|
|
247
|
+
for (;;) {
|
|
248
|
+
const index = nextIndex++;
|
|
249
|
+
if (index >= accounts.length) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const account = accounts[index];
|
|
253
|
+
if (account.type !== "oauth") {
|
|
254
|
+
continue; // api_key accounts have no subscription windows
|
|
255
|
+
}
|
|
256
|
+
// Isolate failures per account: one rejection must not abort the
|
|
257
|
+
// Promise.all sweep or discard the other accounts' refreshed quotas.
|
|
258
|
+
try {
|
|
259
|
+
const result = await fetchAccountUsage(account);
|
|
260
|
+
if (!result.ok) {
|
|
261
|
+
errors.push(`${account.label}: ${result.error}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const quota = usageToQuota(result.usage, {
|
|
265
|
+
now: Date.now(),
|
|
266
|
+
prior: prior[account.label] ?? null,
|
|
267
|
+
});
|
|
268
|
+
if (!quota) {
|
|
269
|
+
errors.push(`${account.label}: usage payload had no recognizable limit windows`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
await saveAccountQuota(account.label, quota);
|
|
273
|
+
quotas[account.label] = quota;
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
errors.push(`${account.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
try {
|
|
281
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, accounts.length || 1) }, () => worker()));
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
// The quota store's debounced flush timer is unref()'d and this process
|
|
285
|
+
// is short-lived — flush now (even on a partial sweep) or the completed
|
|
286
|
+
// saves never reach disk.
|
|
287
|
+
await flushAccountQuotas().catch(() => undefined);
|
|
288
|
+
}
|
|
289
|
+
return { via: "direct", quotas, errors };
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
errors.push(`direct limit fetch failed (${err instanceof Error ? err.message : String(err)})`);
|
|
293
|
+
return { via: "none", quotas: null, errors };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
164
296
|
/**
|
|
165
297
|
* Handle the list subcommand
|
|
166
298
|
* `neurolink auth list`
|
|
@@ -189,6 +321,7 @@ export async function handleList(argv) {
|
|
|
189
321
|
let email;
|
|
190
322
|
let tokenStatus = "unknown";
|
|
191
323
|
let expiresAt;
|
|
324
|
+
let tokenType;
|
|
192
325
|
// Derive email from the compound key label when it looks like an email.
|
|
193
326
|
// The credentials file is a shared singleton that gets overwritten on
|
|
194
327
|
// every login — reading email from it would show the LATEST login's
|
|
@@ -220,6 +353,7 @@ export async function handleList(argv) {
|
|
|
220
353
|
const tokens = await defaultTokenStore.loadTokens(key);
|
|
221
354
|
if (tokens) {
|
|
222
355
|
expiresAt = tokens.expiresAt;
|
|
356
|
+
tokenType = tokens.tokenType;
|
|
223
357
|
const isExpired = defaultTokenStore.isTokenExpired(tokens, 0);
|
|
224
358
|
tokenStatus = isExpired ? "expired" : "valid";
|
|
225
359
|
// Extract per-account metadata from scope (e.g. "tier:pro email:user@example.com")
|
|
@@ -242,9 +376,24 @@ export async function handleList(argv) {
|
|
|
242
376
|
catch {
|
|
243
377
|
// Token load failed — show as unknown
|
|
244
378
|
}
|
|
245
|
-
return {
|
|
379
|
+
return {
|
|
380
|
+
key,
|
|
381
|
+
provider,
|
|
382
|
+
label,
|
|
383
|
+
email,
|
|
384
|
+
tier,
|
|
385
|
+
tokenStatus,
|
|
386
|
+
expiresAt,
|
|
387
|
+
tokenType,
|
|
388
|
+
};
|
|
246
389
|
}));
|
|
247
|
-
//
|
|
390
|
+
// Optionally fetch FRESH limits from Anthropic before rendering.
|
|
391
|
+
let refreshOutcome;
|
|
392
|
+
if (argv.refresh) {
|
|
393
|
+
refreshOutcome = await refreshAccountLimitsForList();
|
|
394
|
+
}
|
|
395
|
+
// Load persisted quota data (captured from proxy responses), then overlay
|
|
396
|
+
// anything just refreshed — freshly fetched values win over the snapshot.
|
|
248
397
|
let quotas = {};
|
|
249
398
|
try {
|
|
250
399
|
quotas = await loadAccountQuotas();
|
|
@@ -252,6 +401,9 @@ export async function handleList(argv) {
|
|
|
252
401
|
catch {
|
|
253
402
|
// Non-fatal — quota display is best-effort
|
|
254
403
|
}
|
|
404
|
+
if (refreshOutcome?.quotas) {
|
|
405
|
+
quotas = { ...quotas, ...refreshOutcome.quotas };
|
|
406
|
+
}
|
|
255
407
|
if (argv.format === "json") {
|
|
256
408
|
// Merge quota data into each account object for JSON output
|
|
257
409
|
const withQuota = enrichedAccounts.map((acct) => {
|
|
@@ -259,9 +411,29 @@ export async function handleList(argv) {
|
|
|
259
411
|
const quota = quotas[quotaKey] ?? null;
|
|
260
412
|
return { ...acct, quota };
|
|
261
413
|
});
|
|
262
|
-
|
|
414
|
+
if (refreshOutcome) {
|
|
415
|
+
// --refresh envelopes the array so the fetch outcome travels with it.
|
|
416
|
+
logger.always(JSON.stringify({
|
|
417
|
+
refresh: {
|
|
418
|
+
via: refreshOutcome.via,
|
|
419
|
+
errors: refreshOutcome.errors,
|
|
420
|
+
},
|
|
421
|
+
accounts: withQuota,
|
|
422
|
+
}, null, 2));
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
logger.always(JSON.stringify(withQuota, null, 2));
|
|
426
|
+
}
|
|
263
427
|
}
|
|
264
428
|
else {
|
|
429
|
+
if (refreshOutcome) {
|
|
430
|
+
if (refreshOutcome.via !== "none") {
|
|
431
|
+
logger.always(chalk.gray(`\nFetched fresh limits from Anthropic (${refreshOutcome.via === "proxy" ? "via running proxy" : "direct"}).`));
|
|
432
|
+
}
|
|
433
|
+
for (const refreshError of refreshOutcome.errors) {
|
|
434
|
+
logger.always(chalk.yellow(`⚠ ${refreshError}`));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
265
437
|
logger.always(chalk.bold("\nAuthenticated Accounts:\n"));
|
|
266
438
|
// Check if any account has quota data to decide column layout
|
|
267
439
|
const hasQuota = enrichedAccounts.some((acct) => {
|
|
@@ -296,14 +468,21 @@ export async function handleList(argv) {
|
|
|
296
468
|
if (hasQuota && quota) {
|
|
297
469
|
const qc = formatQuotaColumns(quota);
|
|
298
470
|
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText} ${qc.sessionText.padEnd(10)} ${qc.weeklyText.padEnd(10)}`);
|
|
471
|
+
const indent = " ".repeat(2 + 20 + 1 + 12 + 1 + 28 + 1 + 14 + 1);
|
|
299
472
|
// Second line: reset times (indented under session/weekly columns)
|
|
300
473
|
if (qc.sessionReset || qc.weeklyReset) {
|
|
301
|
-
const indent = " ".repeat(2 + 20 + 1 + 12 + 1 + 28 + 1 + 14 + 1);
|
|
302
474
|
logger.always(`${indent}${(qc.sessionReset || "").padEnd(10)} ${qc.weeklyReset || ""}`);
|
|
303
475
|
}
|
|
476
|
+
// Dynamic per-plan windows (e.g. the Fable-only weekly limit)
|
|
477
|
+
for (const windowRow of formatQuotaWindowRows(quota)) {
|
|
478
|
+
logger.always(`${indent}${windowRow}`);
|
|
479
|
+
}
|
|
304
480
|
}
|
|
305
481
|
else {
|
|
306
|
-
|
|
482
|
+
const apiKeyNote = refreshOutcome && acct.tokenType && acct.tokenType !== "Bearer"
|
|
483
|
+
? chalk.gray(" (api key — not refreshed)")
|
|
484
|
+
: "";
|
|
485
|
+
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText}${hasQuota ? " - -" : ""}${apiKeyNote}`);
|
|
307
486
|
}
|
|
308
487
|
}
|
|
309
488
|
logger.always("");
|
|
@@ -214,8 +214,14 @@ export class AuthCommandFactory {
|
|
|
214
214
|
*/
|
|
215
215
|
static buildListOptions(yargs) {
|
|
216
216
|
return yargs
|
|
217
|
+
.option("refresh", {
|
|
218
|
+
type: "boolean",
|
|
219
|
+
default: false,
|
|
220
|
+
description: "Fetch fresh limits from Anthropic for all OAuth accounts before listing (via the running proxy when available)",
|
|
221
|
+
})
|
|
217
222
|
.example("$0 auth list", "List all authenticated accounts")
|
|
218
|
-
.example("$0 auth list --format json", "List accounts in JSON format")
|
|
223
|
+
.example("$0 auth list --format json", "List accounts in JSON format")
|
|
224
|
+
.example("$0 auth list --refresh", "Fetch fresh session/weekly/model-scoped limits before listing");
|
|
219
225
|
}
|
|
220
226
|
/**
|
|
221
227
|
* Build options for remove subcommand
|
|
@@ -48,4 +48,10 @@ export declare function loadAccountQuota(accountKey: string): Promise<AccountQuo
|
|
|
48
48
|
* other accounts' snapshots and blinded quota-aware routing to them).
|
|
49
49
|
*/
|
|
50
50
|
export declare function saveAccountQuota(accountKey: string, quota: AccountQuota): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Cancel any pending debounced write and flush the cache to disk now.
|
|
53
|
+
* Short-lived processes (CLI refresh path) must call this before exit —
|
|
54
|
+
* the debounce timer is unref()'d and will not keep the process alive.
|
|
55
|
+
*/
|
|
56
|
+
export declare function flushAccountQuotas(): Promise<void>;
|
|
51
57
|
export declare function flushAccountQuotaStateForTests(): Promise<void>;
|
|
@@ -94,6 +94,7 @@ export function parseQuotaHeaders(headers) {
|
|
|
94
94
|
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
95
95
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
96
96
|
lastUpdated: Date.now(),
|
|
97
|
+
source: "headers",
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
// ---------------------------------------------------------------------------
|
|
@@ -229,17 +230,33 @@ export async function loadAccountQuota(accountKey) {
|
|
|
229
230
|
export async function saveAccountQuota(accountKey, quota) {
|
|
230
231
|
await stateMutex.runExclusive(async () => {
|
|
231
232
|
await ensureAccountQuotasLoaded();
|
|
232
|
-
|
|
233
|
+
const next = { ...quota };
|
|
234
|
+
// Header-sourced saves carry no dynamic windows; a passive capture right
|
|
235
|
+
// after a usage-API refresh must not erase the refreshed buckets.
|
|
236
|
+
const existing = memoryCache[accountKey];
|
|
237
|
+
if (next.windows === undefined && existing?.windows !== undefined) {
|
|
238
|
+
next.windows = existing.windows;
|
|
239
|
+
next.windowsUpdatedAt = existing.windowsUpdatedAt;
|
|
240
|
+
}
|
|
241
|
+
memoryCache[accountKey] = next;
|
|
233
242
|
dirty = true;
|
|
234
243
|
cacheVersion += 1;
|
|
235
244
|
});
|
|
236
245
|
scheduleFlush();
|
|
237
246
|
}
|
|
238
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Cancel any pending debounced write and flush the cache to disk now.
|
|
249
|
+
* Short-lived processes (CLI refresh path) must call this before exit —
|
|
250
|
+
* the debounce timer is unref()'d and will not keep the process alive.
|
|
251
|
+
*/
|
|
252
|
+
export async function flushAccountQuotas() {
|
|
239
253
|
if (flushTimer) {
|
|
240
254
|
clearTimeout(flushTimer);
|
|
241
255
|
flushTimer = null;
|
|
242
256
|
}
|
|
243
257
|
await flushToDisk();
|
|
244
258
|
}
|
|
259
|
+
export async function flushAccountQuotaStateForTests() {
|
|
260
|
+
await flushAccountQuotas();
|
|
261
|
+
}
|
|
245
262
|
//# sourceMappingURL=accountQuota.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-Demand Account Usage Fetch
|
|
3
|
+
*
|
|
4
|
+
* Manual refetch path for account limits: queries Anthropic's OAuth usage
|
|
5
|
+
* endpoint (the same call Claude Code's /usage makes) to get FRESH session /
|
|
6
|
+
* weekly / model-scoped windows for an account without consuming any tokens
|
|
7
|
+
* and without starting a 5h session window.
|
|
8
|
+
*
|
|
9
|
+
* This complements — never replaces — the passive header capture in
|
|
10
|
+
* accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
|
|
11
|
+
* same `AccountQuota` shape so refreshed data flows through the existing
|
|
12
|
+
* save/merge/cooldown chain.
|
|
13
|
+
*
|
|
14
|
+
* Only OAuth (Bearer) accounts have subscription windows; api_key accounts
|
|
15
|
+
* are skipped and keep their header-derived absolute limits.
|
|
16
|
+
*/
|
|
17
|
+
import type { AccountAllowlist, AccountQuota, AccountUsageFetchResult, AnthropicUsageResponse, ProxyPassthroughAccount } from "../types/index.js";
|
|
18
|
+
export declare const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
19
|
+
/**
|
|
20
|
+
* Enumerate stored Anthropic accounts for a usage refresh.
|
|
21
|
+
* Read-only: no token refresh, no disable side effects — those decisions
|
|
22
|
+
* belong to the caller (fetchAccountUsage / the routing path respectively).
|
|
23
|
+
*/
|
|
24
|
+
export declare function listAnthropicAccountsForUsage(allowlist?: AccountAllowlist): Promise<ProxyPassthroughAccount[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch fresh usage windows for one account. Return-not-throw.
|
|
27
|
+
* Refreshes an expiring/expired token first (de-duped, rotation-safe), and
|
|
28
|
+
* retries once through a forced refresh on a 401.
|
|
29
|
+
*/
|
|
30
|
+
export declare function fetchAccountUsage(account: ProxyPassthroughAccount): Promise<AccountUsageFetchResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a usage-endpoint payload into the `AccountQuota` shape used by
|
|
33
|
+
* the passive header path, so a refresh writes through the exact same
|
|
34
|
+
* save/merge/cooldown chain.
|
|
35
|
+
*
|
|
36
|
+
* `prior` supplies fields the usage payload cannot express (fallback and
|
|
37
|
+
* upgrade-path signals) so `isQuotaOverageAvailable` behaves identically
|
|
38
|
+
* before and after a refresh. `unifiedStatus` is deliberately never set:
|
|
39
|
+
* fabricating it would let the reconcile path treat a refresh like a
|
|
40
|
+
* unified-rejected 429.
|
|
41
|
+
*/
|
|
42
|
+
export declare function usageToQuota(usage: AnthropicUsageResponse, opts: {
|
|
43
|
+
now: number;
|
|
44
|
+
prior?: AccountQuota | null;
|
|
45
|
+
}): AccountQuota | null;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-Demand Account Usage Fetch
|
|
3
|
+
*
|
|
4
|
+
* Manual refetch path for account limits: queries Anthropic's OAuth usage
|
|
5
|
+
* endpoint (the same call Claude Code's /usage makes) to get FRESH session /
|
|
6
|
+
* weekly / model-scoped windows for an account without consuming any tokens
|
|
7
|
+
* and without starting a 5h session window.
|
|
8
|
+
*
|
|
9
|
+
* This complements — never replaces — the passive header capture in
|
|
10
|
+
* accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
|
|
11
|
+
* same `AccountQuota` shape so refreshed data flows through the existing
|
|
12
|
+
* save/merge/cooldown chain.
|
|
13
|
+
*
|
|
14
|
+
* Only OAuth (Bearer) accounts have subscription windows; api_key accounts
|
|
15
|
+
* are skipped and keep their header-derived absolute limits.
|
|
16
|
+
*/
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
import { tokenStore } from "../auth/tokenStore.js";
|
|
19
|
+
import { CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../auth/anthropicOAuth.js";
|
|
20
|
+
import { needsRefresh, refreshTokenFromLatest, isPermanentRefreshFailure, persistTokens, } from "./tokenRefresh.js";
|
|
21
|
+
import { isAccountAllowed } from "./accountSelection.js";
|
|
22
|
+
export const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
23
|
+
const USAGE_FETCH_TIMEOUT_MS = 10_000;
|
|
24
|
+
/**
|
|
25
|
+
* Enumerate stored Anthropic accounts for a usage refresh.
|
|
26
|
+
* Read-only: no token refresh, no disable side effects — those decisions
|
|
27
|
+
* belong to the caller (fetchAccountUsage / the routing path respectively).
|
|
28
|
+
*/
|
|
29
|
+
export async function listAnthropicAccountsForUsage(allowlist) {
|
|
30
|
+
const accounts = [];
|
|
31
|
+
const compoundKeys = await tokenStore.listByPrefix("anthropic:");
|
|
32
|
+
for (const key of compoundKeys) {
|
|
33
|
+
if (!isAccountAllowed(key, allowlist)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (await tokenStore.isDisabled(key)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const tokens = await tokenStore.loadTokens(key);
|
|
40
|
+
if (!tokens) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Full suffix after the first ":" — a label may itself contain colons,
|
|
44
|
+
// and the quota store and `auth list` rendering key by the full label.
|
|
45
|
+
const separatorIndex = key.indexOf(":");
|
|
46
|
+
accounts.push({
|
|
47
|
+
key,
|
|
48
|
+
label: separatorIndex >= 0 ? key.slice(separatorIndex + 1) : key,
|
|
49
|
+
token: tokens.accessToken,
|
|
50
|
+
refreshToken: tokens.refreshToken,
|
|
51
|
+
expiresAt: tokens.expiresAt,
|
|
52
|
+
type: tokens.tokenType === "Bearer" ? "oauth" : "api_key",
|
|
53
|
+
persistTarget: { providerKey: key },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return accounts;
|
|
57
|
+
}
|
|
58
|
+
async function requestUsage(token) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(ANTHROPIC_USAGE_URL, {
|
|
61
|
+
method: "GET",
|
|
62
|
+
headers: {
|
|
63
|
+
authorization: `Bearer ${token}`,
|
|
64
|
+
"anthropic-beta": OAUTH_BETA_HEADERS,
|
|
65
|
+
"user-agent": CLAUDE_CLI_USER_AGENT,
|
|
66
|
+
accept: "application/json",
|
|
67
|
+
},
|
|
68
|
+
signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
return { response };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { networkError: err instanceof Error ? err.message : String(err) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetch fresh usage windows for one account. Return-not-throw.
|
|
78
|
+
* Refreshes an expiring/expired token first (de-duped, rotation-safe), and
|
|
79
|
+
* retries once through a forced refresh on a 401.
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchAccountUsage(account) {
|
|
82
|
+
if (account.type !== "oauth") {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "not_oauth",
|
|
86
|
+
error: "api_key accounts have no subscription usage windows",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const runRefresh = async () => {
|
|
90
|
+
const result = await refreshTokenFromLatest(account, account.persistTarget);
|
|
91
|
+
if (result.success) {
|
|
92
|
+
if (account.persistTarget) {
|
|
93
|
+
await persistTokens(account.persistTarget, account);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (isPermanentRefreshFailure(result)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "auth",
|
|
101
|
+
error: "reauth required (refresh token rejected)",
|
|
102
|
+
status: result.status,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const tokenExpired = account.expiresAt
|
|
106
|
+
? account.expiresAt <= Date.now()
|
|
107
|
+
: false;
|
|
108
|
+
return tokenExpired
|
|
109
|
+
? {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "auth",
|
|
112
|
+
error: "token expired and refresh failed",
|
|
113
|
+
status: result.status,
|
|
114
|
+
}
|
|
115
|
+
: null; // token still valid — attempt the fetch with it
|
|
116
|
+
};
|
|
117
|
+
let refreshedThisCall = false;
|
|
118
|
+
if (needsRefresh(account)) {
|
|
119
|
+
refreshedThisCall = true;
|
|
120
|
+
const failure = await runRefresh();
|
|
121
|
+
if (failure) {
|
|
122
|
+
return failure;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let { response, networkError } = await requestUsage(account.token);
|
|
126
|
+
if (response?.status === 401 && account.refreshToken && !refreshedThisCall) {
|
|
127
|
+
const failure = await runRefresh();
|
|
128
|
+
if (failure) {
|
|
129
|
+
return failure;
|
|
130
|
+
}
|
|
131
|
+
({ response, networkError } = await requestUsage(account.token));
|
|
132
|
+
}
|
|
133
|
+
if (!response) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: "network",
|
|
137
|
+
error: networkError ?? "network failure",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const body = await response.text().catch(() => "");
|
|
142
|
+
logger.debug(`[account-usage] usage fetch failed for ${account.label}`, {
|
|
143
|
+
status: response.status,
|
|
144
|
+
body: body.slice(0, 300),
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: response.status === 401 || response.status === 403 ? "auth" : "http",
|
|
149
|
+
error: `usage endpoint returned HTTP ${response.status}`,
|
|
150
|
+
status: response.status,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const usage = (await response.json());
|
|
155
|
+
return { ok: true, usage };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
reason: "parse",
|
|
161
|
+
error: err instanceof Error ? err.message : String(err),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Normalization (pure CPU — unit-testable, no I/O)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
const REJECTED_SEVERITIES = new Set([
|
|
169
|
+
"rejected",
|
|
170
|
+
"exceeded",
|
|
171
|
+
"over_limit",
|
|
172
|
+
"blocked",
|
|
173
|
+
]);
|
|
174
|
+
/**
|
|
175
|
+
* "rejected" iff the window is fully used or the provider severity marks it
|
|
176
|
+
* exhausted; anything unknown stays "allowed". Conservative on purpose: a
|
|
177
|
+
* wrong "rejected" would park an account, a wrong "allowed" just delays
|
|
178
|
+
* parking until the passive capture or the reset. Raw severity is preserved
|
|
179
|
+
* on the window so nothing is lost.
|
|
180
|
+
*/
|
|
181
|
+
function deriveWindowStatus(percent, severity) {
|
|
182
|
+
const sev = severity?.trim().toLowerCase();
|
|
183
|
+
if ((typeof percent === "number" && percent >= 100) ||
|
|
184
|
+
(sev !== undefined && REJECTED_SEVERITIES.has(sev))) {
|
|
185
|
+
return "rejected";
|
|
186
|
+
}
|
|
187
|
+
return "allowed";
|
|
188
|
+
}
|
|
189
|
+
/** ISO-8601 → unix seconds; 0 when absent/unparseable (matches headers). */
|
|
190
|
+
function isoToEpochSeconds(value) {
|
|
191
|
+
if (!value) {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const ms = Date.parse(value);
|
|
195
|
+
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000);
|
|
196
|
+
}
|
|
197
|
+
function toFraction(percent) {
|
|
198
|
+
return typeof percent === "number" && Number.isFinite(percent)
|
|
199
|
+
? percent / 100
|
|
200
|
+
: undefined;
|
|
201
|
+
}
|
|
202
|
+
function mapUsageLimit(entry) {
|
|
203
|
+
const window = {
|
|
204
|
+
kind: entry.kind ?? "unknown",
|
|
205
|
+
used: toFraction(entry.percent) ?? 0,
|
|
206
|
+
status: deriveWindowStatus(entry.percent, entry.severity),
|
|
207
|
+
resetsAt: isoToEpochSeconds(entry.resets_at),
|
|
208
|
+
};
|
|
209
|
+
if (entry.group !== undefined) {
|
|
210
|
+
window.group = entry.group;
|
|
211
|
+
}
|
|
212
|
+
if (entry.severity !== null && entry.severity !== undefined) {
|
|
213
|
+
window.severity = entry.severity;
|
|
214
|
+
}
|
|
215
|
+
if (entry.is_active !== null && entry.is_active !== undefined) {
|
|
216
|
+
window.isActive = entry.is_active;
|
|
217
|
+
}
|
|
218
|
+
const scopeModel = entry.scope?.model?.display_name;
|
|
219
|
+
if (scopeModel) {
|
|
220
|
+
window.scopeModel = scopeModel;
|
|
221
|
+
}
|
|
222
|
+
const scopeSurface = entry.scope?.surface;
|
|
223
|
+
if (scopeSurface) {
|
|
224
|
+
window.scopeSurface = scopeSurface;
|
|
225
|
+
}
|
|
226
|
+
return window;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Normalize a usage-endpoint payload into the `AccountQuota` shape used by
|
|
230
|
+
* the passive header path, so a refresh writes through the exact same
|
|
231
|
+
* save/merge/cooldown chain.
|
|
232
|
+
*
|
|
233
|
+
* `prior` supplies fields the usage payload cannot express (fallback and
|
|
234
|
+
* upgrade-path signals) so `isQuotaOverageAvailable` behaves identically
|
|
235
|
+
* before and after a refresh. `unifiedStatus` is deliberately never set:
|
|
236
|
+
* fabricating it would let the reconcile path treat a refresh like a
|
|
237
|
+
* unified-rejected 429.
|
|
238
|
+
*/
|
|
239
|
+
export function usageToQuota(usage, opts) {
|
|
240
|
+
const limits = Array.isArray(usage.limits) ? usage.limits : [];
|
|
241
|
+
if (!usage.five_hour && !usage.seven_day && limits.length === 0) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const { now, prior } = opts;
|
|
245
|
+
const windows = limits.map(mapUsageLimit);
|
|
246
|
+
const sessionLimit = limits.find((entry) => entry.kind === "session");
|
|
247
|
+
const weeklyLimit = limits.find((entry) => entry.kind === "weekly_all");
|
|
248
|
+
const sessionPct = usage.five_hour?.utilization ?? sessionLimit?.percent ?? undefined;
|
|
249
|
+
const weeklyPct = usage.seven_day?.utilization ?? weeklyLimit?.percent ?? undefined;
|
|
250
|
+
// A refresh must never replace a known reset with 0: the payload omits
|
|
251
|
+
// `resets_at` for untouched windows, and a zero reset would stop
|
|
252
|
+
// reconcileCooldownFromQuota from parking a rejected window and make the
|
|
253
|
+
// routing metrics treat it as non-ticking. A stale prior reset degrades
|
|
254
|
+
// gracefully (past resets are ignored / freshened downstream).
|
|
255
|
+
const sessionResetAt = isoToEpochSeconds(usage.five_hour?.resets_at) ||
|
|
256
|
+
isoToEpochSeconds(sessionLimit?.resets_at) ||
|
|
257
|
+
(prior?.sessionResetAt ?? 0);
|
|
258
|
+
const weeklyResetAt = isoToEpochSeconds(usage.seven_day?.resets_at) ||
|
|
259
|
+
isoToEpochSeconds(weeklyLimit?.resets_at) ||
|
|
260
|
+
(prior?.weeklyResetAt ?? 0);
|
|
261
|
+
const overageEnabled = usage.extra_usage?.is_enabled;
|
|
262
|
+
const overageStatus = overageEnabled === true
|
|
263
|
+
? "allowed"
|
|
264
|
+
: overageEnabled === false
|
|
265
|
+
? "rejected"
|
|
266
|
+
: (prior?.overageStatus ?? "unknown");
|
|
267
|
+
const quota = {
|
|
268
|
+
sessionUsed: toFraction(sessionPct) ?? prior?.sessionUsed ?? 0,
|
|
269
|
+
sessionStatus: deriveWindowStatus(sessionPct, sessionLimit?.severity),
|
|
270
|
+
sessionResetAt,
|
|
271
|
+
weeklyUsed: toFraction(weeklyPct) ?? prior?.weeklyUsed ?? 0,
|
|
272
|
+
weeklyStatus: deriveWindowStatus(weeklyPct, weeklyLimit?.severity),
|
|
273
|
+
weeklyResetAt,
|
|
274
|
+
fallbackPercentage: prior?.fallbackPercentage ?? 0,
|
|
275
|
+
overageStatus,
|
|
276
|
+
lastUpdated: now,
|
|
277
|
+
windows,
|
|
278
|
+
windowsUpdatedAt: now,
|
|
279
|
+
source: "usage-api",
|
|
280
|
+
};
|
|
281
|
+
if (prior?.fallbackStatus !== undefined) {
|
|
282
|
+
quota.fallbackStatus = prior.fallbackStatus;
|
|
283
|
+
}
|
|
284
|
+
if (prior?.upgradePaths !== undefined) {
|
|
285
|
+
quota.upgradePaths = prior.upgradePaths;
|
|
286
|
+
}
|
|
287
|
+
return quota;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=accountUsage.js.map
|