@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
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
|
|
13
13
|
import { ProxyTracer } from "../../proxy/proxyTracer.js";
|
|
14
14
|
import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
|
|
15
|
-
import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, QueuedAccountAdmission, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
15
|
+
import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyLimitsRefreshResponse, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, QueuedAccountAdmission, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
|
|
16
16
|
declare function tryAcquireAccountAdmission(accountKey: string, capacity: number | undefined): AccountAdmissionLease | undefined;
|
|
17
17
|
declare function enqueueAccountAdmission(accountKey: string, capacity: number): QueuedAccountAdmission;
|
|
18
18
|
declare function acquireAccountAdmission(accountKey: string, capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<AccountAdmissionLease | undefined>;
|
|
@@ -74,6 +74,18 @@ declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: A
|
|
|
74
74
|
* gracefully because past reset timestamps are ignored by resetEpochToMs.
|
|
75
75
|
*/
|
|
76
76
|
declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Fetch fresh limits from Anthropic's usage endpoint for every eligible OAuth
|
|
79
|
+
* account and write them through the exact same chain the passive header
|
|
80
|
+
* capture uses (runtime state → cooldown reconciliation → debounced disk
|
|
81
|
+
* snapshot), so routing and `auth list` see the refreshed windows and the
|
|
82
|
+
* automatic path keeps working unchanged on top of them.
|
|
83
|
+
*/
|
|
84
|
+
declare function refreshAccountLimits(options?: {
|
|
85
|
+
accountAllowlist?: AccountAllowlist;
|
|
86
|
+
accountFilter?: string;
|
|
87
|
+
snapshotOnly?: boolean;
|
|
88
|
+
}): Promise<ProxyLimitsRefreshResponse>;
|
|
77
89
|
/**
|
|
78
90
|
* Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
|
|
79
91
|
* spend the overall weekly allowance that expires SOONEST first, so quota
|
|
@@ -345,6 +357,8 @@ export declare const __testHooks: {
|
|
|
345
357
|
maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
|
|
346
358
|
planCooldownFor429: typeof planCooldownFor429;
|
|
347
359
|
reconcileCooldownFromQuota: typeof reconcileCooldownFromQuota;
|
|
360
|
+
refreshAccountLimits: typeof refreshAccountLimits;
|
|
361
|
+
clearLimitsRefreshStateForTests: () => void;
|
|
348
362
|
isRetryableNetworkError: typeof isRetryableNetworkError;
|
|
349
363
|
isPermanentRefreshFailure: typeof isPermanentRefreshFailure;
|
|
350
364
|
getStreamFailureDetails: typeof getStreamFailureDetails;
|
|
@@ -17,6 +17,7 @@ import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_
|
|
|
17
17
|
import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
|
|
18
18
|
import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
19
19
|
import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
20
|
+
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
|
|
20
21
|
import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
|
|
21
22
|
import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
|
|
22
23
|
import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
|
|
@@ -639,6 +640,127 @@ async function seedRuntimeQuotasFromDisk(accounts) {
|
|
|
639
640
|
// Non-fatal: seeding is best-effort; ordering falls back to probe-first.
|
|
640
641
|
}
|
|
641
642
|
}
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Manual limits refresh (GET /limits)
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
/** Minimum spacing between usage-endpoint fetches for one account. Bounds
|
|
647
|
+
* abuse of the ungated endpoint; inside the window the last reading is
|
|
648
|
+
* returned as "throttled" (still fresher than any passive snapshot). */
|
|
649
|
+
const MIN_USAGE_REFETCH_INTERVAL_MS = 15_000;
|
|
650
|
+
const lastUsageFetchAt = new Map();
|
|
651
|
+
let limitsRefreshInFlight = null;
|
|
652
|
+
const USAGE_REFRESH_CONCURRENCY = 4;
|
|
653
|
+
/**
|
|
654
|
+
* Fetch fresh limits from Anthropic's usage endpoint for every eligible OAuth
|
|
655
|
+
* account and write them through the exact same chain the passive header
|
|
656
|
+
* capture uses (runtime state → cooldown reconciliation → debounced disk
|
|
657
|
+
* snapshot), so routing and `auth list` see the refreshed windows and the
|
|
658
|
+
* automatic path keeps working unchanged on top of them.
|
|
659
|
+
*/
|
|
660
|
+
async function refreshAccountLimits(options = {}) {
|
|
661
|
+
const fetchedAt = Date.now();
|
|
662
|
+
const allAccounts = await listAnthropicAccountsForUsage(options.accountAllowlist);
|
|
663
|
+
const accounts = options.accountFilter
|
|
664
|
+
? allAccounts.filter((account) => account.label === options.accountFilter ||
|
|
665
|
+
account.key === options.accountFilter)
|
|
666
|
+
: allAccounts;
|
|
667
|
+
const persisted = await loadAccountQuotas().catch(() => ({}));
|
|
668
|
+
const buildResult = (account, status, quota, error) => {
|
|
669
|
+
const state = accountRuntimeState.get(account.key);
|
|
670
|
+
const result = {
|
|
671
|
+
account: account.label,
|
|
672
|
+
key: account.key,
|
|
673
|
+
type: account.type,
|
|
674
|
+
status,
|
|
675
|
+
quota: quota ?? state?.quota ?? persisted[account.label] ?? null,
|
|
676
|
+
};
|
|
677
|
+
if (error !== undefined) {
|
|
678
|
+
result.error = error;
|
|
679
|
+
}
|
|
680
|
+
if (state?.coolingUntil && state.coolingUntil > Date.now()) {
|
|
681
|
+
result.coolingUntil = state.coolingUntil;
|
|
682
|
+
if (state.coolingReason) {
|
|
683
|
+
result.coolingReason = state.coolingReason;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return result;
|
|
687
|
+
};
|
|
688
|
+
if (options.snapshotOnly) {
|
|
689
|
+
return {
|
|
690
|
+
fetchedAt,
|
|
691
|
+
snapshot: true,
|
|
692
|
+
results: accounts.map((account) => buildResult(account, "snapshot", null)),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const results = new Array(accounts.length);
|
|
696
|
+
let nextIndex = 0;
|
|
697
|
+
const worker = async () => {
|
|
698
|
+
for (;;) {
|
|
699
|
+
const index = nextIndex++;
|
|
700
|
+
if (index >= accounts.length) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const account = accounts[index];
|
|
704
|
+
if (account.type !== "oauth") {
|
|
705
|
+
results[index] = buildResult(account, "skipped_api_key", null);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const lastFetch = lastUsageFetchAt.get(account.key) ?? 0;
|
|
709
|
+
if (Date.now() - lastFetch < MIN_USAGE_REFETCH_INTERVAL_MS) {
|
|
710
|
+
results[index] = buildResult(account, "throttled", null);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
lastUsageFetchAt.set(account.key, Date.now());
|
|
714
|
+
// Isolate failures per account: an unexpected rejection must not abort
|
|
715
|
+
// the Promise.all sweep and turn the whole /limits response into a 502.
|
|
716
|
+
try {
|
|
717
|
+
const fetchResult = await fetchAccountUsage(account);
|
|
718
|
+
// `=== false` (not `!ok`) — the react-hooks sub-build compiles this
|
|
719
|
+
// file without strictNullChecks, where negated boolean-discriminant
|
|
720
|
+
// narrowing does not apply.
|
|
721
|
+
if (fetchResult.ok === false) {
|
|
722
|
+
results[index] = buildResult(account, "error", null, fetchResult.error);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const state = getOrCreateRuntimeState(account.key);
|
|
726
|
+
const capturedAt = Date.now();
|
|
727
|
+
const quota = usageToQuota(fetchResult.usage, {
|
|
728
|
+
now: capturedAt,
|
|
729
|
+
prior: state.quota ?? persisted[account.label] ?? null,
|
|
730
|
+
});
|
|
731
|
+
if (!quota) {
|
|
732
|
+
results[index] = buildResult(account, "error", null, "usage payload had no recognizable limit windows");
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Guard against a passive header capture that landed mid-fetch: never
|
|
736
|
+
// replace a fresher runtime snapshot with an older reading.
|
|
737
|
+
if (quota.lastUpdated >= (state.quota?.lastUpdated ?? 0)) {
|
|
738
|
+
state.quota = quota;
|
|
739
|
+
}
|
|
740
|
+
const cooldownUpdate = reconcileCooldownFromQuota(state, quota, capturedAt);
|
|
741
|
+
if (cooldownUpdate?.kind === "cooled") {
|
|
742
|
+
await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
|
|
743
|
+
// Non-fatal: cooldown is already active in memory.
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
else if (cooldownUpdate?.kind === "cleared") {
|
|
747
|
+
await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
|
|
748
|
+
// Non-fatal: the next successful response will reconcile again.
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
await saveAccountQuota(account.label, quota).catch(() => {
|
|
752
|
+
// Non-fatal: quota persistence is best-effort
|
|
753
|
+
});
|
|
754
|
+
results[index] = buildResult(account, "refreshed", quota);
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
results[index] = buildResult(account, "error", null, err instanceof Error ? err.message : String(err));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
await Promise.all(Array.from({ length: Math.min(USAGE_REFRESH_CONCURRENCY, accounts.length || 1) }, () => worker()));
|
|
762
|
+
return { fetchedAt, snapshot: false, results };
|
|
763
|
+
}
|
|
642
764
|
/** Quota-aware selection is on by default; disable with
|
|
643
765
|
* NEUROLINK_PROXY_QUOTA_ROUTING=off|false|0. Only affects the fill-first
|
|
644
766
|
* strategy (round-robin keeps strict rotation). */
|
|
@@ -5037,6 +5159,45 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
|
|
|
5037
5159
|
description: "Count tokens for a messages request",
|
|
5038
5160
|
tags: ["claude-proxy", "tokens"],
|
|
5039
5161
|
},
|
|
5162
|
+
// =====================================================================
|
|
5163
|
+
// GET /limits -- Fresh account limits from Anthropic's usage endpoint
|
|
5164
|
+
// =====================================================================
|
|
5165
|
+
{
|
|
5166
|
+
method: "GET",
|
|
5167
|
+
path: `${basePath}/limits`,
|
|
5168
|
+
handler: async (ctx) => {
|
|
5169
|
+
const effectiveAllowlist = runtimeConfigProvider
|
|
5170
|
+
? runtimeConfigProvider().accountAllowlist
|
|
5171
|
+
: accountAllowlist;
|
|
5172
|
+
const snapshotOnly = ctx.query?.snapshot === "true" || ctx.query?.snapshot === "1";
|
|
5173
|
+
const accountFilter = ctx.query?.account;
|
|
5174
|
+
return withSpan({
|
|
5175
|
+
name: "neurolink.http.claudeProxy.limits",
|
|
5176
|
+
tracer: tracers.http,
|
|
5177
|
+
attributes: { "http.route": `${basePath}/limits` },
|
|
5178
|
+
}, async () => {
|
|
5179
|
+
// Single-flight: concurrent full refreshes share one sweep.
|
|
5180
|
+
if (!snapshotOnly && !accountFilter) {
|
|
5181
|
+
if (!limitsRefreshInFlight) {
|
|
5182
|
+
limitsRefreshInFlight = refreshAccountLimits({
|
|
5183
|
+
accountAllowlist: effectiveAllowlist,
|
|
5184
|
+
}).finally(() => {
|
|
5185
|
+
limitsRefreshInFlight = null;
|
|
5186
|
+
});
|
|
5187
|
+
}
|
|
5188
|
+
return limitsRefreshInFlight;
|
|
5189
|
+
}
|
|
5190
|
+
return refreshAccountLimits({
|
|
5191
|
+
accountAllowlist: effectiveAllowlist,
|
|
5192
|
+
accountFilter,
|
|
5193
|
+
snapshotOnly,
|
|
5194
|
+
});
|
|
5195
|
+
});
|
|
5196
|
+
},
|
|
5197
|
+
description: "Fetch fresh per-account limits from Anthropic (usage API). " +
|
|
5198
|
+
"?account=<label> for one account, ?snapshot=true for stored state",
|
|
5199
|
+
tags: ["claude-proxy", "limits"],
|
|
5200
|
+
},
|
|
5040
5201
|
],
|
|
5041
5202
|
};
|
|
5042
5203
|
}
|
|
@@ -5340,6 +5501,11 @@ export const __testHooks = {
|
|
|
5340
5501
|
maybeResetPrimaryToHome,
|
|
5341
5502
|
planCooldownFor429,
|
|
5342
5503
|
reconcileCooldownFromQuota,
|
|
5504
|
+
refreshAccountLimits,
|
|
5505
|
+
clearLimitsRefreshStateForTests: () => {
|
|
5506
|
+
lastUsageFetchAt.clear();
|
|
5507
|
+
limitsRefreshInFlight = null;
|
|
5508
|
+
},
|
|
5343
5509
|
isRetryableNetworkError,
|
|
5344
5510
|
isPermanentRefreshFailure,
|
|
5345
5511
|
getStreamFailureDetails,
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { PPTGenerationResult } from "./ppt.js";
|
|
|
11
11
|
import type { AvatarResult } from "./avatar.js";
|
|
12
12
|
import type { MusicResult } from "./music.js";
|
|
13
13
|
import type { OAuthTokens } from "./auth.js";
|
|
14
|
+
import type { AccountQuota } from "./proxy.js";
|
|
14
15
|
import type { ClaudeSubscriptionTier } from "./subscription.js";
|
|
15
16
|
import type { ServerFramework } from "./server.js";
|
|
16
17
|
import type { AuthProviderType } from "./auth.js";
|
|
@@ -947,6 +948,8 @@ export type AuthCommandArgs = BaseCommandArgs & {
|
|
|
947
948
|
label?: string;
|
|
948
949
|
account?: string;
|
|
949
950
|
force?: boolean;
|
|
951
|
+
/** `auth list --refresh`: fetch fresh limits from Anthropic before listing */
|
|
952
|
+
refresh?: boolean;
|
|
950
953
|
/** Path to the proxy config YAML, used by set-/get-/clear-primary */
|
|
951
954
|
config?: string;
|
|
952
955
|
/** Email passed to `auth set-primary <email>` */
|
|
@@ -954,6 +957,15 @@ export type AuthCommandArgs = BaseCommandArgs & {
|
|
|
954
957
|
/** Yargs positional arguments */
|
|
955
958
|
_?: (string | number)[];
|
|
956
959
|
};
|
|
960
|
+
/** Outcome of the `auth list --refresh` fresh-limit fetch. */
|
|
961
|
+
export type AuthListRefreshOutcome = {
|
|
962
|
+
/** How the fresh limits were obtained ("none" when every path failed). */
|
|
963
|
+
via: "proxy" | "direct" | "none";
|
|
964
|
+
/** Freshly fetched quotas keyed by account label; null when none fetched. */
|
|
965
|
+
quotas: Record<string, AccountQuota> | null;
|
|
966
|
+
/** Per-account and transport errors, already formatted for display. */
|
|
967
|
+
errors: string[];
|
|
968
|
+
};
|
|
957
969
|
/** Telemetry command arguments */
|
|
958
970
|
export type TelemetryCommandArgs = {
|
|
959
971
|
format?: "text" | "json" | "table";
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -943,6 +943,107 @@ export type AccountQuota = {
|
|
|
943
943
|
overageStatus: string;
|
|
944
944
|
/** Epoch ms when we last captured this data */
|
|
945
945
|
lastUpdated: number;
|
|
946
|
+
/** Dynamic per-plan limit buckets from the usage API `limits[]` array
|
|
947
|
+
* (session / weekly_all / model-scoped weeklies such as Fable / future
|
|
948
|
+
* kinds). Absent on purely header-sourced snapshots. */
|
|
949
|
+
windows?: AccountQuotaWindow[];
|
|
950
|
+
/** Epoch ms when `windows` was last refreshed from the usage API. */
|
|
951
|
+
windowsUpdatedAt?: number;
|
|
952
|
+
/** Provenance of this snapshot's numbers. */
|
|
953
|
+
source?: AccountQuotaSource;
|
|
954
|
+
};
|
|
955
|
+
/** Where an AccountQuota snapshot came from.
|
|
956
|
+
* - "headers" : passive capture of anthropic-ratelimit-unified-* response
|
|
957
|
+
* headers on a routed request (the automatic path).
|
|
958
|
+
* - "usage-api" : an explicit refresh against Anthropic's OAuth usage
|
|
959
|
+
* endpoint (manual refetch path). */
|
|
960
|
+
export type AccountQuotaSource = "headers" | "usage-api";
|
|
961
|
+
/** One dynamic limit bucket from the usage API. Provider vocabulary (`kind`,
|
|
962
|
+
* `group`, `severity`) is preserved verbatim so buckets Anthropic adds later
|
|
963
|
+
* survive storage and display without a code change. */
|
|
964
|
+
export type AccountQuotaWindow = {
|
|
965
|
+
/** Provider kind, verbatim ("session", "weekly_all", "weekly_scoped", ...). */
|
|
966
|
+
kind: string;
|
|
967
|
+
/** Provider group, verbatim ("session" | "weekly" | future values). */
|
|
968
|
+
group?: string;
|
|
969
|
+
/** 0.0-1.0 utilization (provider percent / 100). */
|
|
970
|
+
used: number;
|
|
971
|
+
/** Provider severity, verbatim ("normal", ...). */
|
|
972
|
+
severity?: string;
|
|
973
|
+
/** Derived "allowed" | "rejected" (see usageToQuota status mapping). */
|
|
974
|
+
status: string;
|
|
975
|
+
/** Unix timestamp (seconds) when this window resets; 0 when unparseable. */
|
|
976
|
+
resetsAt: number;
|
|
977
|
+
isActive?: boolean;
|
|
978
|
+
/** Model display name for model-scoped windows (e.g. "Fable"). */
|
|
979
|
+
scopeModel?: string;
|
|
980
|
+
/** Surface scope when the provider reports one. */
|
|
981
|
+
scopeSurface?: string;
|
|
982
|
+
};
|
|
983
|
+
/** One utilization window from the OAuth usage endpoint (wire shape, loose). */
|
|
984
|
+
export type AnthropicUsageWindow = {
|
|
985
|
+
/** 0-100 percent (note: NOT the 0-1 fraction used by headers). */
|
|
986
|
+
utilization?: number | null;
|
|
987
|
+
/** ISO-8601 timestamp. */
|
|
988
|
+
resets_at?: string | null;
|
|
989
|
+
};
|
|
990
|
+
/** One entry of the usage endpoint's generic `limits[]` array (wire shape). */
|
|
991
|
+
export type AnthropicUsageLimit = {
|
|
992
|
+
kind?: string;
|
|
993
|
+
group?: string;
|
|
994
|
+
/** 0-100 percent. */
|
|
995
|
+
percent?: number | null;
|
|
996
|
+
severity?: string | null;
|
|
997
|
+
resets_at?: string | null;
|
|
998
|
+
scope?: {
|
|
999
|
+
model?: {
|
|
1000
|
+
id?: string | null;
|
|
1001
|
+
display_name?: string | null;
|
|
1002
|
+
} | null;
|
|
1003
|
+
surface?: string | null;
|
|
1004
|
+
} | null;
|
|
1005
|
+
is_active?: boolean | null;
|
|
1006
|
+
};
|
|
1007
|
+
/** Response body of GET https://api.anthropic.com/api/oauth/usage (loose —
|
|
1008
|
+
* unknown keys are ignored, known keys may be absent or null). */
|
|
1009
|
+
export type AnthropicUsageResponse = {
|
|
1010
|
+
five_hour?: AnthropicUsageWindow | null;
|
|
1011
|
+
seven_day?: AnthropicUsageWindow | null;
|
|
1012
|
+
limits?: AnthropicUsageLimit[] | null;
|
|
1013
|
+
extra_usage?: {
|
|
1014
|
+
is_enabled?: boolean | null;
|
|
1015
|
+
} | null;
|
|
1016
|
+
};
|
|
1017
|
+
/** Outcome of one account's usage-endpoint fetch. Return-not-throw. */
|
|
1018
|
+
export type AccountUsageFetchResult = {
|
|
1019
|
+
ok: true;
|
|
1020
|
+
usage: AnthropicUsageResponse;
|
|
1021
|
+
} | {
|
|
1022
|
+
ok: false;
|
|
1023
|
+
reason: "not_oauth" | "auth" | "http" | "network" | "parse";
|
|
1024
|
+
error: string;
|
|
1025
|
+
status?: number;
|
|
1026
|
+
};
|
|
1027
|
+
/** Per-account result inside a GET /limits response. */
|
|
1028
|
+
export type ProxyLimitsAccountResult = {
|
|
1029
|
+
/** Account label (quota-store key). */
|
|
1030
|
+
account: string;
|
|
1031
|
+
/** Token-store key ("anthropic:<label>"). */
|
|
1032
|
+
key: string;
|
|
1033
|
+
type: ProxyAccountType;
|
|
1034
|
+
status: "refreshed" | "throttled" | "skipped_api_key" | "snapshot" | "error";
|
|
1035
|
+
/** Fresh quota on "refreshed"; last known snapshot otherwise (may be null). */
|
|
1036
|
+
quota: AccountQuota | null;
|
|
1037
|
+
error?: string;
|
|
1038
|
+
coolingUntil?: number;
|
|
1039
|
+
coolingReason?: AccountCoolingReason;
|
|
1040
|
+
};
|
|
1041
|
+
/** Response body of the proxy's GET /limits endpoint. */
|
|
1042
|
+
export type ProxyLimitsRefreshResponse = {
|
|
1043
|
+
fetchedAt: number;
|
|
1044
|
+
/** True when served from stored state without contacting Anthropic. */
|
|
1045
|
+
snapshot: boolean;
|
|
1046
|
+
results: ProxyLimitsAccountResult[];
|
|
946
1047
|
};
|
|
947
1048
|
/**
|
|
948
1049
|
* Provenance of the quota numbers attached to a single proxy response.
|