@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.
@@ -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 { key, provider, label, email, tier, tokenStatus, expiresAt };
379
+ return {
380
+ key,
381
+ provider,
382
+ label,
383
+ email,
384
+ tier,
385
+ tokenStatus,
386
+ expiresAt,
387
+ tokenType,
388
+ };
246
389
  }));
247
- // Load persisted quota data (captured from proxy responses).
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
- logger.always(JSON.stringify(withQuota, null, 2));
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
- logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText}${hasQuota ? " - -" : ""}`);
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
- memoryCache[accountKey] = { ...quota };
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
- export async function flushAccountQuotaStateForTests() {
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