@juspay/neurolink 11.1.1 → 11.2.1
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 +12 -0
- package/dist/auth/codexOAuth.d.ts +67 -0
- package/dist/auth/codexOAuth.js +202 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/browser/neurolink.min.js +401 -401
- package/dist/cli/commands/auth.d.ts +27 -8
- package/dist/cli/commands/auth.js +425 -6
- package/dist/cli/commands/proxy.js +230 -5
- package/dist/cli/factories/authCommandFactory.d.ts +8 -0
- package/dist/cli/factories/authCommandFactory.js +74 -1
- package/dist/lib/auth/codexOAuth.d.ts +67 -0
- package/dist/lib/auth/codexOAuth.js +203 -0
- package/dist/lib/auth/index.d.ts +1 -0
- package/dist/lib/auth/index.js +4 -0
- package/dist/lib/providers/configuredOpenAICompat.d.ts +24 -0
- package/dist/lib/providers/configuredOpenAICompat.js +60 -0
- package/dist/lib/providers/openaiCompatCatalog.d.ts +24 -0
- package/dist/lib/providers/openaiCompatCatalog.js +272 -0
- package/dist/lib/proxy/accountCooldown.js +35 -2
- package/dist/lib/proxy/accountQuota.d.ts +29 -3
- package/dist/lib/proxy/accountQuota.js +203 -12
- package/dist/lib/proxy/accountUsage.js +15 -2
- package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/lib/proxy/codexAccountUsage.js +174 -0
- package/dist/lib/proxy/proxyAnalysis.js +12 -1
- package/dist/lib/proxy/proxyConfig.js +24 -0
- package/dist/lib/proxy/routingEvidence.d.ts +12 -1
- package/dist/lib/proxy/routingEvidence.js +23 -0
- package/dist/lib/proxy/runtimeConfig.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
- package/dist/lib/types/cli.d.ts +7 -1
- package/dist/lib/types/codex.d.ts +95 -0
- package/dist/lib/types/codex.js +15 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/providers.d.ts +99 -0
- package/dist/lib/types/proxy.d.ts +83 -0
- package/dist/lib/types/subscription.d.ts +13 -0
- package/dist/lib/utils/providerConfig.d.ts +23 -1
- package/dist/lib/utils/providerConfig.js +60 -0
- package/dist/providers/configuredOpenAICompat.d.ts +24 -0
- package/dist/providers/configuredOpenAICompat.js +59 -0
- package/dist/providers/openaiCompatCatalog.d.ts +24 -0
- package/dist/providers/openaiCompatCatalog.js +271 -0
- package/dist/proxy/accountCooldown.js +35 -2
- package/dist/proxy/accountQuota.d.ts +29 -3
- package/dist/proxy/accountQuota.js +203 -12
- package/dist/proxy/accountUsage.js +15 -2
- package/dist/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/proxy/codexAccountUsage.js +173 -0
- package/dist/proxy/proxyAnalysis.js +12 -1
- package/dist/proxy/proxyConfig.js +24 -0
- package/dist/proxy/routingEvidence.d.ts +12 -1
- package/dist/proxy/routingEvidence.js +23 -0
- package/dist/proxy/runtimeConfig.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/server/routes/codexProxyRoutes.js +453 -0
- package/dist/types/cli.d.ts +7 -1
- package/dist/types/codex.d.ts +95 -0
- package/dist/types/codex.js +14 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +99 -0
- package/dist/types/proxy.d.ts +83 -0
- package/dist/types/subscription.d.ts +13 -0
- package/dist/utils/providerConfig.d.ts +23 -1
- package/dist/utils/providerConfig.js +60 -0
- package/package.json +3 -1
|
@@ -33,6 +33,82 @@ function getHeader(headers, name) {
|
|
|
33
33
|
}
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
|
+
/** Enumerate header names, working for both `Headers` and a plain record. */
|
|
37
|
+
function forEachHeaderName(headers, visit) {
|
|
38
|
+
if (typeof headers.forEach === "function") {
|
|
39
|
+
headers.forEach((_value, name) => visit(name));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const name of Object.keys(headers)) {
|
|
43
|
+
visit(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Collapse a wire model id to its family by dropping the snapshot date, so
|
|
48
|
+
* `claude-fable-5-20260115` and `claude-fable-5-20260320` both tag the same
|
|
49
|
+
* scoped window. Without this a window would stop matching the day Anthropic
|
|
50
|
+
* ships a new snapshot.
|
|
51
|
+
*/
|
|
52
|
+
export function modelFamilyToken(model) {
|
|
53
|
+
return model
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/[-_](latest)$/i, "")
|
|
56
|
+
.replace(/-\d{6,8}$/, "");
|
|
57
|
+
}
|
|
58
|
+
/** Unified header window tokens that map to the flat session/weekly fields. */
|
|
59
|
+
const FLAT_UNIFIED_WINDOW_TOKENS = new Set(["5h", "7d"]);
|
|
60
|
+
const UNIFIED_UTILIZATION_HEADER = /^anthropic-ratelimit-unified-([a-z0-9_]+)-utilization$/;
|
|
61
|
+
/**
|
|
62
|
+
* Discover model-scoped rate-limit windows from response headers.
|
|
63
|
+
*
|
|
64
|
+
* Anthropic reports a per-model weekly cap as its own header family — today
|
|
65
|
+
* `anthropic-ratelimit-unified-7d_oi-*`, sent only on responses for the model
|
|
66
|
+
* that cap applies to. The token is matched generically rather than hardcoded
|
|
67
|
+
* so a future `7d_xx` is captured without a code change, mirroring how
|
|
68
|
+
* `mapUsageLimit` preserves the provider's vocabulary verbatim.
|
|
69
|
+
*
|
|
70
|
+
* Requires the request's model: the header states a limit but never says which
|
|
71
|
+
* model it scopes, and an untagged window cannot be matched to a later request.
|
|
72
|
+
*/
|
|
73
|
+
function parseScopedQuotaWindows(headers, model, now) {
|
|
74
|
+
if (!model) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const scopeModel = modelFamilyToken(model);
|
|
78
|
+
if (!scopeModel) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const tokens = [];
|
|
82
|
+
forEachHeaderName(headers, (name) => {
|
|
83
|
+
const match = UNIFIED_UTILIZATION_HEADER.exec(name.toLowerCase());
|
|
84
|
+
if (match?.[1] && !FLAT_UNIFIED_WINDOW_TOKENS.has(match[1])) {
|
|
85
|
+
tokens.push(match[1]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const windows = [];
|
|
89
|
+
for (const token of tokens) {
|
|
90
|
+
const P = `anthropic-ratelimit-unified-${token}-`;
|
|
91
|
+
const used = parseFloat(getHeader(headers, `${P}utilization`) ?? "");
|
|
92
|
+
if (Number.isNaN(used)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const resetRaw = getHeader(headers, `${P}reset`);
|
|
96
|
+
const status = getHeader(headers, `${P}status`)?.trim().toLowerCase();
|
|
97
|
+
windows.push({
|
|
98
|
+
kind: token.startsWith("7d") ? "weekly_scoped" : "session_scoped",
|
|
99
|
+
group: token.startsWith("7d") ? "weekly" : "session",
|
|
100
|
+
used,
|
|
101
|
+
status: status ?? "unknown",
|
|
102
|
+
resetsAt: resetRaw ? parseInt(resetRaw, 10) || 0 : 0,
|
|
103
|
+
scopeModel,
|
|
104
|
+
scopeModelId: model,
|
|
105
|
+
headerWindow: token,
|
|
106
|
+
source: "headers",
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return windows;
|
|
111
|
+
}
|
|
36
112
|
/** Read and normalize Anthropic's authoritative top-level unified status. */
|
|
37
113
|
export function getUnifiedRateLimitStatus(headers) {
|
|
38
114
|
const value = getHeader(headers, "anthropic-ratelimit-unified-status");
|
|
@@ -48,7 +124,25 @@ export function getUnifiedRateLimitStatus(headers) {
|
|
|
48
124
|
* equivalent provider state.
|
|
49
125
|
*/
|
|
50
126
|
export function isQuotaOverageAvailable(quota) {
|
|
51
|
-
|
|
127
|
+
// `extra_usage.is_enabled` from the usage API is the account's own setting and
|
|
128
|
+
// is reported even for an account that has never served a request, which the
|
|
129
|
+
// header signals below cannot cover. Positive only: it is refreshed far less
|
|
130
|
+
// often than headers are, so a stale `false` must not veto live evidence that
|
|
131
|
+
// overage is actually serving.
|
|
132
|
+
//
|
|
133
|
+
// It is also sticky — the merge carries it forward whenever a payload omits
|
|
134
|
+
// `extra_usage` — so a live header saying overage is switched off must be
|
|
135
|
+
// able to veto it. Without that veto an org disabling extra usage would leave
|
|
136
|
+
// the flag true forever, suppressing every cooldown and sending request after
|
|
137
|
+
// request that is certain to 429.
|
|
138
|
+
const overageStatus = quota?.overageStatus?.trim().toLowerCase();
|
|
139
|
+
const providerDisabledOverage = overageStatus === "rejected" || quota?.overageDisabledReason !== undefined;
|
|
140
|
+
if (quota?.overageEnabled === true && !providerDisabledOverage) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
// Explicit null check: overageStatus is now read before this point, so the
|
|
144
|
+
// optional-chain no longer narrows `quota` for the accesses below.
|
|
145
|
+
if (!quota || overageStatus !== "allowed") {
|
|
52
146
|
return false;
|
|
53
147
|
}
|
|
54
148
|
if (quota.overageInUse === true) {
|
|
@@ -69,7 +163,7 @@ export function isQuotaOverageAvailable(quota) {
|
|
|
69
163
|
* Returns `null` when key headers are absent.
|
|
70
164
|
* Pure computation — no I/O, no blocking.
|
|
71
165
|
*/
|
|
72
|
-
export function parseQuotaHeaders(headers) {
|
|
166
|
+
export function parseQuotaHeaders(headers, opts) {
|
|
73
167
|
// Anthropic prefixes all quota headers with "anthropic-ratelimit-"
|
|
74
168
|
const P = "anthropic-ratelimit-";
|
|
75
169
|
const sessionUtilRaw = getHeader(headers, `${P}unified-5h-utilization`);
|
|
@@ -85,6 +179,10 @@ export function parseQuotaHeaders(headers) {
|
|
|
85
179
|
const sessionResetRaw = getHeader(headers, `${P}unified-5h-reset`);
|
|
86
180
|
const weeklyResetRaw = getHeader(headers, `${P}unified-7d-reset`);
|
|
87
181
|
const fallbackRaw = getHeader(headers, `${P}unified-fallback-percentage`);
|
|
182
|
+
const now = opts?.now ?? Date.now();
|
|
183
|
+
const scopedWindows = parseScopedQuotaWindows(headers, opts?.model, now);
|
|
184
|
+
const overageDisabledReason = getHeader(headers, `${P}unified-overage-disabled-reason`);
|
|
185
|
+
const representativeClaim = getHeader(headers, `${P}unified-representative-claim`);
|
|
88
186
|
return {
|
|
89
187
|
unifiedStatus: getUnifiedRateLimitStatus(headers),
|
|
90
188
|
sessionUsed,
|
|
@@ -94,15 +192,114 @@ export function parseQuotaHeaders(headers) {
|
|
|
94
192
|
weeklyStatus: getHeader(headers, `${P}unified-7d-status`) ?? "unknown",
|
|
95
193
|
weeklyResetAt: weeklyResetRaw ? parseInt(weeklyResetRaw, 10) || 0 : 0,
|
|
96
194
|
fallbackPercentage: fallbackRaw ? parseFloat(fallbackRaw) || 0 : 0,
|
|
195
|
+
// Anthropic does not send `unified-fallback` on the current wire, so this is
|
|
196
|
+
// always "unknown" in practice, which keeps the legacy back-compat branch of
|
|
197
|
+
// isQuotaOverageAvailable inert. Left as-is deliberately: making that branch
|
|
198
|
+
// reachable would stop cooling accounts that today park correctly, and the
|
|
199
|
+
// authoritative extra-usage signal now comes from `overageEnabled` instead.
|
|
97
200
|
fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
|
|
98
201
|
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
99
202
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
100
203
|
overageInUse: getHeader(headers, `${P}unified-overage-in-use`)?.trim().toLowerCase() ===
|
|
101
204
|
"true",
|
|
102
|
-
|
|
205
|
+
...(overageDisabledReason ? { overageDisabledReason } : {}),
|
|
206
|
+
...(representativeClaim ? { representativeClaim } : {}),
|
|
207
|
+
lastUpdated: now,
|
|
103
208
|
source: "headers",
|
|
209
|
+
...(scopedWindows.length > 0 ? { windows: scopedWindows } : {}),
|
|
104
210
|
};
|
|
105
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Identity of a window across refreshes.
|
|
214
|
+
*
|
|
215
|
+
* Scope identity uses `scopeModel` — the model *family* on header-derived
|
|
216
|
+
* windows — ahead of the dated wire id, so a new model snapshot updates the
|
|
217
|
+
* existing window instead of appending a second one for the same cap and
|
|
218
|
+
* growing the array on every release. `source` keeps the two providers' views
|
|
219
|
+
* of the same cap distinct, since they name it differently and are reconciled
|
|
220
|
+
* by freshness rather than merged.
|
|
221
|
+
*/
|
|
222
|
+
function quotaWindowKey(window) {
|
|
223
|
+
return [
|
|
224
|
+
window.kind,
|
|
225
|
+
window.source ?? "usage-api",
|
|
226
|
+
window.headerWindow ?? "",
|
|
227
|
+
window.scopeModel ?? window.scopeModelId ?? "",
|
|
228
|
+
window.scopeSurface ?? "",
|
|
229
|
+
].join("|");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Merge dynamic limit windows across snapshots from different sources.
|
|
233
|
+
*
|
|
234
|
+
* The two sources see different things and neither is a superset: the usage API
|
|
235
|
+
* reports every plan bucket but only when explicitly refreshed, while response
|
|
236
|
+
* headers report only the window(s) touched by the request just served — but do
|
|
237
|
+
* so continuously. A plain overwrite in either direction loses real data, which
|
|
238
|
+
* is why a header capture used to erase the model-scoped windows a `/limits`
|
|
239
|
+
* refresh had just fetched.
|
|
240
|
+
*/
|
|
241
|
+
export function mergeQuotaWindows(existing, incoming) {
|
|
242
|
+
if (!incoming?.length) {
|
|
243
|
+
return existing;
|
|
244
|
+
}
|
|
245
|
+
if (!existing?.length) {
|
|
246
|
+
return incoming;
|
|
247
|
+
}
|
|
248
|
+
const merged = new Map();
|
|
249
|
+
const incomingFromUsageApi = incoming.some((window) => (window.source ?? "usage-api") === "usage-api");
|
|
250
|
+
for (const window of existing) {
|
|
251
|
+
// A usage-API sweep is authoritative for every bucket it reports, but it
|
|
252
|
+
// never reports the header-only scoped windows — so those are carried over.
|
|
253
|
+
if (incomingFromUsageApi && (window.source ?? "usage-api") !== "headers") {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
merged.set(quotaWindowKey(window), window);
|
|
257
|
+
}
|
|
258
|
+
for (const window of incoming) {
|
|
259
|
+
merged.set(quotaWindowKey(window), window);
|
|
260
|
+
}
|
|
261
|
+
return [...merged.values()];
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Fold a freshly observed snapshot onto the previous one for the same account,
|
|
265
|
+
* preserving dynamic windows the new snapshot does not carry.
|
|
266
|
+
*/
|
|
267
|
+
export function mergeQuotaSnapshot(previous, incoming) {
|
|
268
|
+
if (!previous) {
|
|
269
|
+
return incoming;
|
|
270
|
+
}
|
|
271
|
+
const windows = mergeQuotaWindows(previous.windows, incoming.windows);
|
|
272
|
+
const next = { ...incoming };
|
|
273
|
+
if (windows !== undefined) {
|
|
274
|
+
next.windows = windows;
|
|
275
|
+
}
|
|
276
|
+
// Account configuration, not per-response state: each source reports only
|
|
277
|
+
// some of these, so a plain overwrite makes the value flicker in and out
|
|
278
|
+
// depending on which source wrote last. `overageEnabled` comes only from the
|
|
279
|
+
// usage API and `overageDisabledReason` only from response headers, so
|
|
280
|
+
// whichever wrote last would otherwise erase the other's field.
|
|
281
|
+
if (next.overageEnabled === undefined &&
|
|
282
|
+
previous.overageEnabled !== undefined) {
|
|
283
|
+
next.overageEnabled = previous.overageEnabled;
|
|
284
|
+
}
|
|
285
|
+
if (next.overageDisabledReason === undefined &&
|
|
286
|
+
previous.overageDisabledReason !== undefined) {
|
|
287
|
+
next.overageDisabledReason = previous.overageDisabledReason;
|
|
288
|
+
}
|
|
289
|
+
if (next.representativeClaim === undefined &&
|
|
290
|
+
previous.representativeClaim !== undefined) {
|
|
291
|
+
next.representativeClaim = previous.representativeClaim;
|
|
292
|
+
}
|
|
293
|
+
// windowsUpdatedAt tracks the last full usage-API sweep; a header capture
|
|
294
|
+
// adds one window and must not claim to have refreshed all of them.
|
|
295
|
+
const windowsUpdatedAt = incoming.source === "usage-api"
|
|
296
|
+
? incoming.windowsUpdatedAt
|
|
297
|
+
: (incoming.windowsUpdatedAt ?? previous.windowsUpdatedAt);
|
|
298
|
+
if (windowsUpdatedAt !== undefined) {
|
|
299
|
+
next.windowsUpdatedAt = windowsUpdatedAt;
|
|
300
|
+
}
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
106
303
|
// ---------------------------------------------------------------------------
|
|
107
304
|
// In-memory cache + debounced async persistence
|
|
108
305
|
// ---------------------------------------------------------------------------
|
|
@@ -236,15 +433,9 @@ export async function loadAccountQuota(accountKey) {
|
|
|
236
433
|
export async function saveAccountQuota(accountKey, quota) {
|
|
237
434
|
await stateMutex.runExclusive(async () => {
|
|
238
435
|
await ensureAccountQuotasLoaded();
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
const existing = memoryCache[accountKey];
|
|
243
|
-
if (next.windows === undefined && existing?.windows !== undefined) {
|
|
244
|
-
next.windows = existing.windows;
|
|
245
|
-
next.windowsUpdatedAt = existing.windowsUpdatedAt;
|
|
246
|
-
}
|
|
247
|
-
memoryCache[accountKey] = next;
|
|
436
|
+
// A header capture reports only the windows the served request touched, so
|
|
437
|
+
// it must fold onto the existing snapshot rather than replace it.
|
|
438
|
+
memoryCache[accountKey] = mergeQuotaSnapshot(memoryCache[accountKey], quota);
|
|
248
439
|
dirty = true;
|
|
249
440
|
cacheVersion += 1;
|
|
250
441
|
});
|
|
@@ -199,12 +199,14 @@ function toFraction(percent) {
|
|
|
199
199
|
? percent / 100
|
|
200
200
|
: undefined;
|
|
201
201
|
}
|
|
202
|
-
function mapUsageLimit(entry) {
|
|
202
|
+
function mapUsageLimit(entry, now) {
|
|
203
203
|
const window = {
|
|
204
204
|
kind: entry.kind ?? "unknown",
|
|
205
205
|
used: toFraction(entry.percent) ?? 0,
|
|
206
206
|
status: deriveWindowStatus(entry.percent, entry.severity),
|
|
207
207
|
resetsAt: isoToEpochSeconds(entry.resets_at),
|
|
208
|
+
source: "usage-api",
|
|
209
|
+
updatedAt: now,
|
|
208
210
|
};
|
|
209
211
|
if (entry.group !== undefined) {
|
|
210
212
|
window.group = entry.group;
|
|
@@ -219,6 +221,13 @@ function mapUsageLimit(entry) {
|
|
|
219
221
|
if (scopeModel) {
|
|
220
222
|
window.scopeModel = scopeModel;
|
|
221
223
|
}
|
|
224
|
+
// The wire id matches a request's `model` exactly, so keeping it lets routing
|
|
225
|
+
// skip the fuzzy display-name match ("Fable" vs "claude-fable-5-20260115").
|
|
226
|
+
// Often null in practice, which is why the display-name path still exists.
|
|
227
|
+
const scopeModelId = entry.scope?.model?.id;
|
|
228
|
+
if (scopeModelId) {
|
|
229
|
+
window.scopeModelId = scopeModelId;
|
|
230
|
+
}
|
|
222
231
|
const scopeSurface = entry.scope?.surface;
|
|
223
232
|
if (scopeSurface) {
|
|
224
233
|
window.scopeSurface = scopeSurface;
|
|
@@ -242,7 +251,7 @@ export function usageToQuota(usage, opts) {
|
|
|
242
251
|
return null;
|
|
243
252
|
}
|
|
244
253
|
const { now, prior } = opts;
|
|
245
|
-
const windows = limits.map(mapUsageLimit);
|
|
254
|
+
const windows = limits.map((entry) => mapUsageLimit(entry, now));
|
|
246
255
|
const sessionLimit = limits.find((entry) => entry.kind === "session");
|
|
247
256
|
const weeklyLimit = limits.find((entry) => entry.kind === "weekly_all");
|
|
248
257
|
const sessionPct = usage.five_hour?.utilization ?? sessionLimit?.percent ?? undefined;
|
|
@@ -273,6 +282,10 @@ export function usageToQuota(usage, opts) {
|
|
|
273
282
|
weeklyResetAt,
|
|
274
283
|
fallbackPercentage: prior?.fallbackPercentage ?? 0,
|
|
275
284
|
overageStatus,
|
|
285
|
+
// Authoritative: the header trio the legacy overage checks rely on is only
|
|
286
|
+
// ever sent on a served response, so an account refreshed but not yet used
|
|
287
|
+
// would otherwise look overage-ineligible even with extra usage switched on.
|
|
288
|
+
...(typeof overageEnabled === "boolean" ? { overageEnabled } : {}),
|
|
276
289
|
lastUpdated: now,
|
|
277
290
|
windows,
|
|
278
291
|
windowsUpdatedAt: now,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex account enumeration + usage/quota normalisation.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `accountUsage.ts` (Anthropic) for the Codex pool. Codex reports two
|
|
5
|
+
* rate-limit windows — `primary` (short) and `secondary` (weekly) — which we
|
|
6
|
+
* map onto the shared AccountQuota session/weekly fields so the same routing,
|
|
7
|
+
* cooldown, and display code works for both providers.
|
|
8
|
+
*/
|
|
9
|
+
import type { AccountQuota, CodexRateLimits, CodexUsageFetchResult, ProxyPassthroughAccount } from "../types/index.js";
|
|
10
|
+
export declare const CODEX_ACCOUNT_PREFIX = "codex:";
|
|
11
|
+
/** Enumerate Codex OAuth accounts from the token store for usage refresh. */
|
|
12
|
+
export declare function listCodexAccountsForUsage(): Promise<ProxyPassthroughAccount[]>;
|
|
13
|
+
/** Normalise a Codex rate-limit block into the shared AccountQuota shape. */
|
|
14
|
+
export declare function codexRateLimitsToQuota(rateLimits: CodexRateLimits | null | undefined, now?: number): AccountQuota;
|
|
15
|
+
/**
|
|
16
|
+
* Parse Codex rate-limit information from response headers. The ChatGPT backend
|
|
17
|
+
* returns a JSON rate-limit blob in `x-codex-ratelimit` / `x-codex-active-limit`
|
|
18
|
+
* on some responses; parse defensively and return null when absent.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseCodexRateLimitHeaders(headers: Headers, now?: number): AccountQuota | null;
|
|
21
|
+
/** Fetch the usage/limits window for one Codex account. */
|
|
22
|
+
export declare function fetchCodexAccountUsage(account: ProxyPassthroughAccount, options?: {
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}): Promise<CodexUsageFetchResult>;
|
|
25
|
+
/** Decode plan type from an account's access token (display convenience). */
|
|
26
|
+
export declare function codexAccountPlanType(account: ProxyPassthroughAccount): string | undefined;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex account enumeration + usage/quota normalisation.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `accountUsage.ts` (Anthropic) for the Codex pool. Codex reports two
|
|
5
|
+
* rate-limit windows — `primary` (short) and `secondary` (weekly) — which we
|
|
6
|
+
* map onto the shared AccountQuota session/weekly fields so the same routing,
|
|
7
|
+
* cooldown, and display code works for both providers.
|
|
8
|
+
*/
|
|
9
|
+
import { tokenStore } from "../auth/tokenStore.js";
|
|
10
|
+
import { CODEX_ORIGINATOR, CODEX_USAGE_URL, CODEX_USER_AGENT, decodeCodexAccessToken, resolveCodexAccountId, } from "../auth/codexOAuth.js";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
12
|
+
export const CODEX_ACCOUNT_PREFIX = "codex:";
|
|
13
|
+
/** Enumerate Codex OAuth accounts from the token store for usage refresh. */
|
|
14
|
+
export async function listCodexAccountsForUsage() {
|
|
15
|
+
const keys = await tokenStore.listByPrefix(CODEX_ACCOUNT_PREFIX);
|
|
16
|
+
const accounts = [];
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (await tokenStore.isDisabled(key)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const tokens = await tokenStore.loadTokens(key);
|
|
22
|
+
if (!tokens) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const label = key.slice(CODEX_ACCOUNT_PREFIX.length) || key;
|
|
26
|
+
accounts.push({
|
|
27
|
+
key,
|
|
28
|
+
label,
|
|
29
|
+
token: tokens.accessToken,
|
|
30
|
+
refreshToken: tokens.refreshToken,
|
|
31
|
+
expiresAt: tokens.expiresAt,
|
|
32
|
+
type: tokens.tokenType === "Bearer" ? "oauth" : "api_key",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return accounts;
|
|
36
|
+
}
|
|
37
|
+
function toFraction(percent) {
|
|
38
|
+
if (typeof percent !== "number" || !Number.isFinite(percent)) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
// Codex reports 0-100; clamp and normalise to 0-1.
|
|
42
|
+
return Math.min(1, Math.max(0, percent / 100));
|
|
43
|
+
}
|
|
44
|
+
function windowResetEpochSeconds(window, nowSeconds) {
|
|
45
|
+
if (!window) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (typeof window.resets_at === "number" &&
|
|
49
|
+
Number.isFinite(window.resets_at) &&
|
|
50
|
+
window.resets_at > 0) {
|
|
51
|
+
// Tolerate ms epochs (~year 2100 ceiling in seconds).
|
|
52
|
+
return window.resets_at > 4_102_444_800
|
|
53
|
+
? Math.floor(window.resets_at / 1000)
|
|
54
|
+
: window.resets_at;
|
|
55
|
+
}
|
|
56
|
+
const relative = typeof window.resets_in_seconds === "number"
|
|
57
|
+
? window.resets_in_seconds
|
|
58
|
+
: window.reset_after;
|
|
59
|
+
if (typeof relative === "number" &&
|
|
60
|
+
Number.isFinite(relative) &&
|
|
61
|
+
relative > 0) {
|
|
62
|
+
return nowSeconds + Math.floor(relative);
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
function deriveWindowStatus(usedFraction) {
|
|
67
|
+
return usedFraction >= 1 ? "rejected" : "allowed";
|
|
68
|
+
}
|
|
69
|
+
function toQuotaWindow(kind, group, window, nowSeconds) {
|
|
70
|
+
if (!window) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const used = toFraction(window.used_percent);
|
|
74
|
+
return {
|
|
75
|
+
kind,
|
|
76
|
+
group,
|
|
77
|
+
used,
|
|
78
|
+
status: deriveWindowStatus(used),
|
|
79
|
+
resetsAt: windowResetEpochSeconds(window, nowSeconds),
|
|
80
|
+
isActive: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Normalise a Codex rate-limit block into the shared AccountQuota shape. */
|
|
84
|
+
export function codexRateLimitsToQuota(rateLimits, now = Date.now()) {
|
|
85
|
+
const nowSeconds = Math.floor(now / 1000);
|
|
86
|
+
const primary = rateLimits?.primary ?? null;
|
|
87
|
+
const secondary = rateLimits?.secondary ?? null;
|
|
88
|
+
const sessionUsed = toFraction(primary?.used_percent);
|
|
89
|
+
const weeklyUsed = toFraction(secondary?.used_percent);
|
|
90
|
+
const windows = [];
|
|
91
|
+
const primaryWindow = toQuotaWindow("session", "session", primary, nowSeconds);
|
|
92
|
+
const secondaryWindow = toQuotaWindow("weekly_all", "weekly", secondary, nowSeconds);
|
|
93
|
+
if (primaryWindow) {
|
|
94
|
+
windows.push(primaryWindow);
|
|
95
|
+
}
|
|
96
|
+
if (secondaryWindow) {
|
|
97
|
+
windows.push(secondaryWindow);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
sessionUsed,
|
|
101
|
+
sessionStatus: deriveWindowStatus(sessionUsed),
|
|
102
|
+
sessionResetAt: windowResetEpochSeconds(primary, nowSeconds),
|
|
103
|
+
weeklyUsed,
|
|
104
|
+
weeklyStatus: deriveWindowStatus(weeklyUsed),
|
|
105
|
+
weeklyResetAt: windowResetEpochSeconds(secondary, nowSeconds),
|
|
106
|
+
// Codex has no overage/fallback concept; keep neutral defaults.
|
|
107
|
+
fallbackPercentage: 0,
|
|
108
|
+
overageStatus: "rejected",
|
|
109
|
+
lastUpdated: now,
|
|
110
|
+
windows: windows.length > 0 ? windows : undefined,
|
|
111
|
+
windowsUpdatedAt: windows.length > 0 ? now : undefined,
|
|
112
|
+
source: "usage-api",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse Codex rate-limit information from response headers. The ChatGPT backend
|
|
117
|
+
* returns a JSON rate-limit blob in `x-codex-ratelimit` / `x-codex-active-limit`
|
|
118
|
+
* on some responses; parse defensively and return null when absent.
|
|
119
|
+
*/
|
|
120
|
+
export function parseCodexRateLimitHeaders(headers, now = Date.now()) {
|
|
121
|
+
const raw = headers.get("x-codex-ratelimit") ??
|
|
122
|
+
headers.get("x-codex-active-limit") ??
|
|
123
|
+
null;
|
|
124
|
+
if (!raw) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(raw);
|
|
129
|
+
const rateLimits = "rate_limits" in parsed && parsed.rate_limits
|
|
130
|
+
? parsed.rate_limits
|
|
131
|
+
: parsed;
|
|
132
|
+
return codexRateLimitsToQuota(rateLimits, now);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Fetch the usage/limits window for one Codex account. */
|
|
139
|
+
export async function fetchCodexAccountUsage(account, options = {}) {
|
|
140
|
+
if (account.type !== "oauth") {
|
|
141
|
+
return { ok: false, reason: "not_oauth" };
|
|
142
|
+
}
|
|
143
|
+
const accountId = resolveCodexAccountId(account.token);
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch(CODEX_USAGE_URL, {
|
|
146
|
+
method: "GET",
|
|
147
|
+
headers: {
|
|
148
|
+
Authorization: `Bearer ${account.token}`,
|
|
149
|
+
...(accountId ? { "chatgpt-account-id": accountId } : {}),
|
|
150
|
+
originator: CODEX_ORIGINATOR,
|
|
151
|
+
"User-Agent": CODEX_USER_AGENT,
|
|
152
|
+
Accept: "application/json",
|
|
153
|
+
},
|
|
154
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
155
|
+
});
|
|
156
|
+
if (response.status === 401 || response.status === 403) {
|
|
157
|
+
return { ok: false, reason: "auth" };
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
return { ok: false, reason: "http" };
|
|
161
|
+
}
|
|
162
|
+
const usage = (await response.json());
|
|
163
|
+
return { ok: true, quota: codexRateLimitsToQuota(usage.rate_limits) };
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
logger.debug(`Codex usage fetch failed for ${account.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
+
return { ok: false, reason: "network" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Decode plan type from an account's access token (display convenience). */
|
|
171
|
+
export function codexAccountPlanType(account) {
|
|
172
|
+
return decodeCodexAccessToken(account.token).planType;
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=codexAccountUsage.js.map
|
|
@@ -100,7 +100,13 @@ function routingCandidateValue(value) {
|
|
|
100
100
|
"sessionStatus",
|
|
101
101
|
"weeklyStatus",
|
|
102
102
|
];
|
|
103
|
-
const optionalNullableStringFields = [
|
|
103
|
+
const optionalNullableStringFields = [
|
|
104
|
+
"fallbackStatus",
|
|
105
|
+
"upgradePaths",
|
|
106
|
+
"scopedModel",
|
|
107
|
+
"scopedStatus",
|
|
108
|
+
];
|
|
109
|
+
const optionalNullableNumberFields = ["scopedUsed", "scopedResetAt"];
|
|
104
110
|
if (!stringValue(candidate.account) ||
|
|
105
111
|
typeof candidate.accountType !== "string" ||
|
|
106
112
|
!ROUTING_ACCOUNT_TYPES.has(candidate.accountType) ||
|
|
@@ -139,6 +145,7 @@ function routingCandidateValue(value) {
|
|
|
139
145
|
"lastRefreshAttemptAt",
|
|
140
146
|
"lastRefreshSuccessAt",
|
|
141
147
|
"nextRefreshEligibleAt",
|
|
148
|
+
...optionalNullableNumberFields,
|
|
142
149
|
].some((field) => field in candidate &&
|
|
143
150
|
candidate[field] !== undefined &&
|
|
144
151
|
!isNullableFiniteNumber(candidate[field])) ||
|
|
@@ -192,6 +199,10 @@ function routingCandidateValue(value) {
|
|
|
192
199
|
weeklyStatus: candidate.weeklyStatus,
|
|
193
200
|
weeklyUsed: candidate.weeklyUsed,
|
|
194
201
|
weeklyResetAt: candidate.weeklyResetAt,
|
|
202
|
+
scopedModel: candidate.scopedModel,
|
|
203
|
+
scopedStatus: candidate.scopedStatus,
|
|
204
|
+
scopedUsed: candidate.scopedUsed,
|
|
205
|
+
scopedResetAt: candidate.scopedResetAt,
|
|
195
206
|
};
|
|
196
207
|
}
|
|
197
208
|
function routingDecisionValue(value) {
|
|
@@ -238,6 +238,16 @@ export function validateProxyConfig(config) {
|
|
|
238
238
|
normalizedQuotaRouting !== "false") {
|
|
239
239
|
errors.push("routing.quota-routing must be a boolean");
|
|
240
240
|
}
|
|
241
|
+
// Without this a typo (`use-overage: nevr`) loads cleanly and silently
|
|
242
|
+
// falls back to "auto" — the operator asked to block paid extra usage and
|
|
243
|
+
// gets provider-driven overage instead.
|
|
244
|
+
const rawUseOverage = routing["use-overage"] ?? routing.useOverage;
|
|
245
|
+
if (rawUseOverage !== undefined &&
|
|
246
|
+
!["auto", "always", "never"].includes(typeof rawUseOverage === "string"
|
|
247
|
+
? rawUseOverage.trim().toLowerCase()
|
|
248
|
+
: "")) {
|
|
249
|
+
errors.push("routing.use-overage must be auto, always, or never");
|
|
250
|
+
}
|
|
241
251
|
const rawAutoFallback = routing["auto-fallback"] ?? routing.autoFallback;
|
|
242
252
|
const normalizedAutoFallback = typeof rawAutoFallback === "string"
|
|
243
253
|
? rawAutoFallback.trim().toLowerCase()
|
|
@@ -417,6 +427,20 @@ function parseRoutingConfig(raw) {
|
|
|
417
427
|
logger.warn(`[proxy-config] Ignoring routing.quotaRouting: expected boolean, got ${typeof rawQuotaRouting}`);
|
|
418
428
|
}
|
|
419
429
|
}
|
|
430
|
+
const rawUseOverage = raw["use-overage"] ?? raw.useOverage;
|
|
431
|
+
if (rawUseOverage !== undefined) {
|
|
432
|
+
const normalized = typeof rawUseOverage === "string"
|
|
433
|
+
? rawUseOverage.trim().toLowerCase()
|
|
434
|
+
: "";
|
|
435
|
+
if (normalized === "auto" ||
|
|
436
|
+
normalized === "always" ||
|
|
437
|
+
normalized === "never") {
|
|
438
|
+
result.useOverage = normalized;
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
logger.warn(`[proxy-config] Ignoring routing.useOverage: expected auto|always|never, got ${String(rawUseOverage)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
420
444
|
const rawAutoFallback = raw["auto-fallback"] ?? raw.autoFallback;
|
|
421
445
|
if (rawAutoFallback !== undefined) {
|
|
422
446
|
if (typeof rawAutoFallback === "boolean") {
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
/** Schema-v1 routing evidence values shared by emitters and offline readers. */
|
|
2
2
|
export declare const PROXY_ACCOUNT_ROUTING_STRATEGIES: readonly ["round-robin", "fill-first"];
|
|
3
3
|
export declare const PROXY_ACCOUNT_ROUTING_MODES: readonly ["quota", "primary", "round_robin", "single_account"];
|
|
4
|
-
export declare const PROXY_ACCOUNT_ROUTING_REASONS: readonly ["single_account", "round_robin", "configured_primary", "insertion_order", "availability", "cooldown_recovery", "quota_evidence", "quota_probe", "session_headroom", "session_reset", "weekly_reset", "weekly_utilization"];
|
|
4
|
+
export declare const PROXY_ACCOUNT_ROUTING_REASONS: readonly ["single_account", "round_robin", "configured_primary", "insertion_order", "availability", "cooldown_recovery", "quota_evidence", "quota_probe", "session_headroom", "scoped_headroom", "session_reset", "weekly_reset", "weekly_utilization", "scoped_utilization"];
|
|
5
5
|
export declare const PROXY_ACCOUNT_TYPES: readonly ["oauth", "api_key"];
|
|
6
6
|
export declare const ACCOUNT_COOLING_REASONS: readonly ["weekly", "session", "unified", "transient", "auth"];
|
|
7
|
+
/**
|
|
8
|
+
* Longest a cooldown may last for each reason, measured from when it was set.
|
|
9
|
+
*
|
|
10
|
+
* A reason names a specific provider window, so it also bounds the wait: a
|
|
11
|
+
* "session" cooldown describes a 5-hour window and can never legitimately run
|
|
12
|
+
* for days. Without a per-reason ceiling a single bogus reset timestamp parks an
|
|
13
|
+
* account for as long as the global 8-day clamp allows — observed in the wild as
|
|
14
|
+
* a 206-hour "session" cooldown. Values carry slack so a genuine window that
|
|
15
|
+
* resets slightly late is not cut short.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MAX_COOLDOWN_MS_BY_REASON: Record<string, number>;
|
|
@@ -21,9 +21,11 @@ export const PROXY_ACCOUNT_ROUTING_REASONS = [
|
|
|
21
21
|
// decisions must never select a production request for quota discovery.
|
|
22
22
|
"quota_probe",
|
|
23
23
|
"session_headroom",
|
|
24
|
+
"scoped_headroom",
|
|
24
25
|
"session_reset",
|
|
25
26
|
"weekly_reset",
|
|
26
27
|
"weekly_utilization",
|
|
28
|
+
"scoped_utilization",
|
|
27
29
|
];
|
|
28
30
|
export const PROXY_ACCOUNT_TYPES = ["oauth", "api_key"];
|
|
29
31
|
export const ACCOUNT_COOLING_REASONS = [
|
|
@@ -33,4 +35,25 @@ export const ACCOUNT_COOLING_REASONS = [
|
|
|
33
35
|
"transient",
|
|
34
36
|
"auth",
|
|
35
37
|
];
|
|
38
|
+
/**
|
|
39
|
+
* Longest a cooldown may last for each reason, measured from when it was set.
|
|
40
|
+
*
|
|
41
|
+
* A reason names a specific provider window, so it also bounds the wait: a
|
|
42
|
+
* "session" cooldown describes a 5-hour window and can never legitimately run
|
|
43
|
+
* for days. Without a per-reason ceiling a single bogus reset timestamp parks an
|
|
44
|
+
* account for as long as the global 8-day clamp allows — observed in the wild as
|
|
45
|
+
* a 206-hour "session" cooldown. Values carry slack so a genuine window that
|
|
46
|
+
* resets slightly late is not cut short.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_COOLDOWN_MS_BY_REASON = {
|
|
49
|
+
session: 5 * 60 * 60 * 1000 + 15 * 60 * 1000,
|
|
50
|
+
weekly: 7 * 24 * 60 * 60 * 1000 + 12 * 60 * 60 * 1000,
|
|
51
|
+
// No named window bounds this one: it fires when the 5h/7d statuses still read
|
|
52
|
+
// "allowed" and the provider's own Retry-After is the only signal. Generous on
|
|
53
|
+
// purpose — truncating a provider-directed wait just re-hammers the account on
|
|
54
|
+
// a shorter cycle — while still refusing an absurd multi-day park.
|
|
55
|
+
unified: 12 * 60 * 60 * 1000,
|
|
56
|
+
transient: 15 * 60 * 1000,
|
|
57
|
+
auth: 5 * 60 * 1000,
|
|
58
|
+
};
|
|
36
59
|
//# sourceMappingURL=routingEvidence.js.map
|
|
@@ -228,6 +228,7 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
|
|
|
228
228
|
: "[missing]")
|
|
229
229
|
.digest("hex")
|
|
230
230
|
.slice(0, 16);
|
|
231
|
+
const useOverage = routing?.useOverage ?? "auto";
|
|
231
232
|
const fingerprintSource = JSON.stringify({
|
|
232
233
|
strategy,
|
|
233
234
|
passthrough: options.passthrough,
|
|
@@ -237,6 +238,7 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
|
|
|
237
238
|
quotaRoutingEnabled,
|
|
238
239
|
sessionSoftLimit,
|
|
239
240
|
sessionResetToleranceMs,
|
|
241
|
+
useOverage,
|
|
240
242
|
});
|
|
241
243
|
const configHash = createHash("sha256")
|
|
242
244
|
.update(fingerprintSource)
|
|
@@ -256,6 +258,7 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
|
|
|
256
258
|
quotaRoutingEnabled,
|
|
257
259
|
sessionSoftLimit,
|
|
258
260
|
sessionResetToleranceMs,
|
|
261
|
+
useOverage,
|
|
259
262
|
}),
|
|
260
263
|
configFilePresent,
|
|
261
264
|
envFilePresent,
|