@juspay/neurolink 10.11.2 → 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 +12 -0
- package/dist/adapters/audioFormatSupport.d.ts +53 -0
- package/dist/adapters/audioFormatSupport.js +200 -0
- package/dist/browser/neurolink.min.js +399 -398
- 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/adapters/audioFormatSupport.d.ts +53 -0
- package/dist/lib/adapters/audioFormatSupport.js +201 -0
- package/dist/lib/processors/archive/ArchiveProcessor.d.ts +37 -0
- package/dist/lib/processors/archive/ArchiveProcessor.js +347 -32
- package/dist/lib/providers/googleAiStudio/client.d.ts +17 -0
- package/dist/lib/providers/googleAiStudio/client.js +45 -19
- package/dist/lib/providers/googleNativeGemini3/utils.d.ts +22 -1
- package/dist/lib/providers/googleNativeGemini3/utils.js +54 -0
- package/dist/lib/providers/googleVertex/client.js +3 -0
- 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/file.d.ts +41 -0
- package/dist/lib/types/generate.d.ts +12 -1
- package/dist/lib/types/processor.d.ts +20 -1
- package/dist/lib/types/providers.d.ts +7 -0
- package/dist/lib/types/proxy.d.ts +101 -0
- package/dist/lib/utils/fileDetector.d.ts +27 -0
- package/dist/lib/utils/fileDetector.js +130 -7
- package/dist/lib/utils/imageProcessor.js +31 -0
- package/dist/lib/utils/messageBuilder.d.ts +0 -9
- package/dist/lib/utils/messageBuilder.js +380 -56
- package/dist/processors/archive/ArchiveProcessor.d.ts +37 -0
- package/dist/processors/archive/ArchiveProcessor.js +347 -32
- package/dist/providers/googleAiStudio/client.d.ts +17 -0
- package/dist/providers/googleAiStudio/client.js +45 -19
- package/dist/providers/googleNativeGemini3/utils.d.ts +22 -1
- package/dist/providers/googleNativeGemini3/utils.js +54 -0
- package/dist/providers/googleVertex/client.js +3 -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/file.d.ts +41 -0
- package/dist/types/generate.d.ts +12 -1
- package/dist/types/processor.d.ts +20 -1
- package/dist/types/providers.d.ts +7 -0
- package/dist/types/proxy.d.ts +101 -0
- package/dist/utils/fileDetector.d.ts +27 -0
- package/dist/utils/fileDetector.js +130 -7
- package/dist/utils/imageProcessor.js +31 -0
- package/dist/utils/messageBuilder.d.ts +0 -9
- package/dist/utils/messageBuilder.js +380 -56
- package/package.json +3 -2
|
@@ -12,6 +12,7 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { extname } from "node:path";
|
|
14
14
|
import { DEFAULT_CONTEXT_GUARD_RATIO, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../../core/constants.js";
|
|
15
|
+
import { needsAudioTranscode, toProviderCompatibleAudio, } from "../../adapters/audioFormatSupport.js";
|
|
15
16
|
import { logger } from "../../utils/logger.js";
|
|
16
17
|
import { resolveSamplingParams } from "../../models/modelRegistry.js";
|
|
17
18
|
import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../../utils/schemaConversion.js";
|
|
@@ -1432,6 +1433,53 @@ conversationMessages) {
|
|
|
1432
1433
|
* is skipped rather than aborting the entire request, matching prior
|
|
1433
1434
|
* Vertex behaviour.
|
|
1434
1435
|
*/
|
|
1436
|
+
/**
|
|
1437
|
+
* Append audio to a Gemini request as `inlineData` parts.
|
|
1438
|
+
*
|
|
1439
|
+
* Shared by both Gemini front ends. Vertex assembles its request here and AI
|
|
1440
|
+
* Studio assembles it in `buildUserPartsWithMultimodal`; when this lived only in
|
|
1441
|
+
* the Vertex client, AI Studio advertised audio support through
|
|
1442
|
+
* `NATIVE_AUDIO_PROVIDERS` and then silently dropped the bytes.
|
|
1443
|
+
*
|
|
1444
|
+
* Gemini's native request shape is assembled directly rather than taken from the
|
|
1445
|
+
* AI SDK's `file` parts, so audio has to be added explicitly the same way PDFs
|
|
1446
|
+
* and images are — a `{ type: "file" }` part built upstream simply never
|
|
1447
|
+
* reaches this request body. That asymmetry is why attaching a recording
|
|
1448
|
+
* produced only the metadata summary even after the message builder learned to
|
|
1449
|
+
* carry the bytes.
|
|
1450
|
+
*
|
|
1451
|
+
* A container Gemini does not accept is converted first; one that cannot be
|
|
1452
|
+
* converted is skipped rather than sent, because an unsupported inlineData
|
|
1453
|
+
* mimeType fails the whole request, and the caller still has the metadata
|
|
1454
|
+
* summary in the text part.
|
|
1455
|
+
*/
|
|
1456
|
+
export async function appendNativeAudioParts(userParts, audioFiles, logPrefix = "[GeminiNative]") {
|
|
1457
|
+
if (!audioFiles || audioFiles.length === 0) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
for (const audio of audioFiles) {
|
|
1461
|
+
// Split on both separators: a Windows-style name reaching a POSIX host
|
|
1462
|
+
// would otherwise keep its whole path, and the extension lookup below
|
|
1463
|
+
// needs the bare filename.
|
|
1464
|
+
const base = audio.filename.split(/[\\/]/).pop() ?? audio.filename;
|
|
1465
|
+
const dot = base.lastIndexOf(".");
|
|
1466
|
+
const extension = dot > 0 ? base.slice(dot) : ".bin";
|
|
1467
|
+
const compatible = await toProviderCompatibleAudio(audio.buffer, audio.mimeType, extension);
|
|
1468
|
+
if (needsAudioTranscode(compatible.mimeType)) {
|
|
1469
|
+
logger.warn(`${logPrefix} Skipping native audio for ${base}: ${compatible.mimeType} ` +
|
|
1470
|
+
`is not accepted and could not be converted. The metadata summary was ` +
|
|
1471
|
+
`still included.`);
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
userParts.push({
|
|
1475
|
+
inlineData: {
|
|
1476
|
+
mimeType: compatible.mimeType,
|
|
1477
|
+
data: compatible.buffer.toString("base64"),
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
logger.debug(`${logPrefix} Added native audio part for ${base} (${compatible.mimeType})`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1435
1483
|
export async function buildUserPartsWithMultimodal(input, textOverride, logPrefix = "[GeminiNative]") {
|
|
1436
1484
|
const text = typeof textOverride === "string" ? textOverride : (input?.text ?? "");
|
|
1437
1485
|
const parts = [{ text }];
|
|
@@ -1533,6 +1581,12 @@ export async function buildUserPartsWithMultimodal(input, textOverride, logPrefi
|
|
|
1533
1581
|
});
|
|
1534
1582
|
}
|
|
1535
1583
|
}
|
|
1584
|
+
// Audio last, and through the same helper the Vertex client uses. AI Studio
|
|
1585
|
+
// never touches `buildMultimodalMessagesArray` — it overrides generate() and
|
|
1586
|
+
// stream() and assembles its request here — so wiring audio only into the
|
|
1587
|
+
// Vertex client left this front end advertising native audio via
|
|
1588
|
+
// NATIVE_AUDIO_PROVIDERS and then dropping the bytes on the floor.
|
|
1589
|
+
await appendNativeAudioParts(parts, input?.nativeAudioFiles, logPrefix);
|
|
1536
1590
|
return parts;
|
|
1537
1591
|
}
|
|
1538
1592
|
//# sourceMappingURL=utils.js.map
|
|
@@ -6,6 +6,7 @@ import os from "os";
|
|
|
6
6
|
import { AIProviderName, ErrorCategory, ErrorSeverity, } from "../../constants/enums.js";
|
|
7
7
|
import { BaseProvider } from "../../core/baseProvider.js";
|
|
8
8
|
import { unwrapImagePayload } from "../../adapters/imageFormatSupport.js";
|
|
9
|
+
import { appendNativeAudioParts } from "../googleNativeGemini3/utils.js";
|
|
9
10
|
import { getMimeTypeForExtension } from "../../processors/config/mimeConstants.js";
|
|
10
11
|
import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_EXECUTION_TIMEOUT_MS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../../core/constants.js";
|
|
11
12
|
import { ModelConfigurationManager } from "../../core/modelConfiguration.js";
|
|
@@ -1403,6 +1404,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1403
1404
|
});
|
|
1404
1405
|
}
|
|
1405
1406
|
}
|
|
1407
|
+
await appendNativeAudioParts(userParts, multimodalInput?.nativeAudioFiles, "[GoogleVertex]");
|
|
1406
1408
|
// Add images as inlineData parts if present
|
|
1407
1409
|
if (multimodalInput?.images && multimodalInput.images.length > 0) {
|
|
1408
1410
|
logger.debug(`[GoogleVertex] Processing ${multimodalInput.images.length} image(s) for native stream`);
|
|
@@ -2408,6 +2410,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2408
2410
|
});
|
|
2409
2411
|
}
|
|
2410
2412
|
}
|
|
2413
|
+
await appendNativeAudioParts(userParts, multimodalInput?.nativeAudioFiles, "[GoogleVertex]");
|
|
2411
2414
|
// Add images as inlineData parts if present
|
|
2412
2415
|
if (multimodalInput?.images && multimodalInput.images.length > 0) {
|
|
2413
2416
|
logger.debug(`[GoogleVertex] Processing ${multimodalInput.images.length} image(s) for native generate`);
|
|
@@ -48,4 +48,10 @@ export declare function loadAccountQuota(accountKey: string): Promise<AccountQuo
|
|
|
48
48
|
* other accounts' snapshots and blinded quota-aware routing to them).
|
|
49
49
|
*/
|
|
50
50
|
export declare function saveAccountQuota(accountKey: string, quota: AccountQuota): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Cancel any pending debounced write and flush the cache to disk now.
|
|
53
|
+
* Short-lived processes (CLI refresh path) must call this before exit —
|
|
54
|
+
* the debounce timer is unref()'d and will not keep the process alive.
|
|
55
|
+
*/
|
|
56
|
+
export declare function flushAccountQuotas(): Promise<void>;
|
|
51
57
|
export declare function flushAccountQuotaStateForTests(): Promise<void>;
|
|
@@ -94,6 +94,7 @@ export function parseQuotaHeaders(headers) {
|
|
|
94
94
|
upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
|
|
95
95
|
overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
|
|
96
96
|
lastUpdated: Date.now(),
|
|
97
|
+
source: "headers",
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
// ---------------------------------------------------------------------------
|
|
@@ -229,17 +230,33 @@ export async function loadAccountQuota(accountKey) {
|
|
|
229
230
|
export async function saveAccountQuota(accountKey, quota) {
|
|
230
231
|
await stateMutex.runExclusive(async () => {
|
|
231
232
|
await ensureAccountQuotasLoaded();
|
|
232
|
-
|
|
233
|
+
const next = { ...quota };
|
|
234
|
+
// Header-sourced saves carry no dynamic windows; a passive capture right
|
|
235
|
+
// after a usage-API refresh must not erase the refreshed buckets.
|
|
236
|
+
const existing = memoryCache[accountKey];
|
|
237
|
+
if (next.windows === undefined && existing?.windows !== undefined) {
|
|
238
|
+
next.windows = existing.windows;
|
|
239
|
+
next.windowsUpdatedAt = existing.windowsUpdatedAt;
|
|
240
|
+
}
|
|
241
|
+
memoryCache[accountKey] = next;
|
|
233
242
|
dirty = true;
|
|
234
243
|
cacheVersion += 1;
|
|
235
244
|
});
|
|
236
245
|
scheduleFlush();
|
|
237
246
|
}
|
|
238
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Cancel any pending debounced write and flush the cache to disk now.
|
|
249
|
+
* Short-lived processes (CLI refresh path) must call this before exit —
|
|
250
|
+
* the debounce timer is unref()'d and will not keep the process alive.
|
|
251
|
+
*/
|
|
252
|
+
export async function flushAccountQuotas() {
|
|
239
253
|
if (flushTimer) {
|
|
240
254
|
clearTimeout(flushTimer);
|
|
241
255
|
flushTimer = null;
|
|
242
256
|
}
|
|
243
257
|
await flushToDisk();
|
|
244
258
|
}
|
|
259
|
+
export async function flushAccountQuotaStateForTests() {
|
|
260
|
+
await flushAccountQuotas();
|
|
261
|
+
}
|
|
245
262
|
//# sourceMappingURL=accountQuota.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-Demand Account Usage Fetch
|
|
3
|
+
*
|
|
4
|
+
* Manual refetch path for account limits: queries Anthropic's OAuth usage
|
|
5
|
+
* endpoint (the same call Claude Code's /usage makes) to get FRESH session /
|
|
6
|
+
* weekly / model-scoped windows for an account without consuming any tokens
|
|
7
|
+
* and without starting a 5h session window.
|
|
8
|
+
*
|
|
9
|
+
* This complements — never replaces — the passive header capture in
|
|
10
|
+
* accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
|
|
11
|
+
* same `AccountQuota` shape so refreshed data flows through the existing
|
|
12
|
+
* save/merge/cooldown chain.
|
|
13
|
+
*
|
|
14
|
+
* Only OAuth (Bearer) accounts have subscription windows; api_key accounts
|
|
15
|
+
* are skipped and keep their header-derived absolute limits.
|
|
16
|
+
*/
|
|
17
|
+
import type { AccountAllowlist, AccountQuota, AccountUsageFetchResult, AnthropicUsageResponse, ProxyPassthroughAccount } from "../types/index.js";
|
|
18
|
+
export declare const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
19
|
+
/**
|
|
20
|
+
* Enumerate stored Anthropic accounts for a usage refresh.
|
|
21
|
+
* Read-only: no token refresh, no disable side effects — those decisions
|
|
22
|
+
* belong to the caller (fetchAccountUsage / the routing path respectively).
|
|
23
|
+
*/
|
|
24
|
+
export declare function listAnthropicAccountsForUsage(allowlist?: AccountAllowlist): Promise<ProxyPassthroughAccount[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch fresh usage windows for one account. Return-not-throw.
|
|
27
|
+
* Refreshes an expiring/expired token first (de-duped, rotation-safe), and
|
|
28
|
+
* retries once through a forced refresh on a 401.
|
|
29
|
+
*/
|
|
30
|
+
export declare function fetchAccountUsage(account: ProxyPassthroughAccount): Promise<AccountUsageFetchResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a usage-endpoint payload into the `AccountQuota` shape used by
|
|
33
|
+
* the passive header path, so a refresh writes through the exact same
|
|
34
|
+
* save/merge/cooldown chain.
|
|
35
|
+
*
|
|
36
|
+
* `prior` supplies fields the usage payload cannot express (fallback and
|
|
37
|
+
* upgrade-path signals) so `isQuotaOverageAvailable` behaves identically
|
|
38
|
+
* before and after a refresh. `unifiedStatus` is deliberately never set:
|
|
39
|
+
* fabricating it would let the reconcile path treat a refresh like a
|
|
40
|
+
* unified-rejected 429.
|
|
41
|
+
*/
|
|
42
|
+
export declare function usageToQuota(usage: AnthropicUsageResponse, opts: {
|
|
43
|
+
now: number;
|
|
44
|
+
prior?: AccountQuota | null;
|
|
45
|
+
}): AccountQuota | null;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-Demand Account Usage Fetch
|
|
3
|
+
*
|
|
4
|
+
* Manual refetch path for account limits: queries Anthropic's OAuth usage
|
|
5
|
+
* endpoint (the same call Claude Code's /usage makes) to get FRESH session /
|
|
6
|
+
* weekly / model-scoped windows for an account without consuming any tokens
|
|
7
|
+
* and without starting a 5h session window.
|
|
8
|
+
*
|
|
9
|
+
* This complements — never replaces — the passive header capture in
|
|
10
|
+
* accountQuota.ts: `usageToQuota` normalizes the endpoint payload into the
|
|
11
|
+
* same `AccountQuota` shape so refreshed data flows through the existing
|
|
12
|
+
* save/merge/cooldown chain.
|
|
13
|
+
*
|
|
14
|
+
* Only OAuth (Bearer) accounts have subscription windows; api_key accounts
|
|
15
|
+
* are skipped and keep their header-derived absolute limits.
|
|
16
|
+
*/
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
import { tokenStore } from "../auth/tokenStore.js";
|
|
19
|
+
import { CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../auth/anthropicOAuth.js";
|
|
20
|
+
import { needsRefresh, refreshTokenFromLatest, isPermanentRefreshFailure, persistTokens, } from "./tokenRefresh.js";
|
|
21
|
+
import { isAccountAllowed } from "./accountSelection.js";
|
|
22
|
+
export const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
23
|
+
const USAGE_FETCH_TIMEOUT_MS = 10_000;
|
|
24
|
+
/**
|
|
25
|
+
* Enumerate stored Anthropic accounts for a usage refresh.
|
|
26
|
+
* Read-only: no token refresh, no disable side effects — those decisions
|
|
27
|
+
* belong to the caller (fetchAccountUsage / the routing path respectively).
|
|
28
|
+
*/
|
|
29
|
+
export async function listAnthropicAccountsForUsage(allowlist) {
|
|
30
|
+
const accounts = [];
|
|
31
|
+
const compoundKeys = await tokenStore.listByPrefix("anthropic:");
|
|
32
|
+
for (const key of compoundKeys) {
|
|
33
|
+
if (!isAccountAllowed(key, allowlist)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (await tokenStore.isDisabled(key)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const tokens = await tokenStore.loadTokens(key);
|
|
40
|
+
if (!tokens) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Full suffix after the first ":" — a label may itself contain colons,
|
|
44
|
+
// and the quota store and `auth list` rendering key by the full label.
|
|
45
|
+
const separatorIndex = key.indexOf(":");
|
|
46
|
+
accounts.push({
|
|
47
|
+
key,
|
|
48
|
+
label: separatorIndex >= 0 ? key.slice(separatorIndex + 1) : key,
|
|
49
|
+
token: tokens.accessToken,
|
|
50
|
+
refreshToken: tokens.refreshToken,
|
|
51
|
+
expiresAt: tokens.expiresAt,
|
|
52
|
+
type: tokens.tokenType === "Bearer" ? "oauth" : "api_key",
|
|
53
|
+
persistTarget: { providerKey: key },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return accounts;
|
|
57
|
+
}
|
|
58
|
+
async function requestUsage(token) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(ANTHROPIC_USAGE_URL, {
|
|
61
|
+
method: "GET",
|
|
62
|
+
headers: {
|
|
63
|
+
authorization: `Bearer ${token}`,
|
|
64
|
+
"anthropic-beta": OAUTH_BETA_HEADERS,
|
|
65
|
+
"user-agent": CLAUDE_CLI_USER_AGENT,
|
|
66
|
+
accept: "application/json",
|
|
67
|
+
},
|
|
68
|
+
signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
return { response };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return { networkError: err instanceof Error ? err.message : String(err) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetch fresh usage windows for one account. Return-not-throw.
|
|
78
|
+
* Refreshes an expiring/expired token first (de-duped, rotation-safe), and
|
|
79
|
+
* retries once through a forced refresh on a 401.
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchAccountUsage(account) {
|
|
82
|
+
if (account.type !== "oauth") {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "not_oauth",
|
|
86
|
+
error: "api_key accounts have no subscription usage windows",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const runRefresh = async () => {
|
|
90
|
+
const result = await refreshTokenFromLatest(account, account.persistTarget);
|
|
91
|
+
if (result.success) {
|
|
92
|
+
if (account.persistTarget) {
|
|
93
|
+
await persistTokens(account.persistTarget, account);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (isPermanentRefreshFailure(result)) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "auth",
|
|
101
|
+
error: "reauth required (refresh token rejected)",
|
|
102
|
+
status: result.status,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const tokenExpired = account.expiresAt
|
|
106
|
+
? account.expiresAt <= Date.now()
|
|
107
|
+
: false;
|
|
108
|
+
return tokenExpired
|
|
109
|
+
? {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "auth",
|
|
112
|
+
error: "token expired and refresh failed",
|
|
113
|
+
status: result.status,
|
|
114
|
+
}
|
|
115
|
+
: null; // token still valid — attempt the fetch with it
|
|
116
|
+
};
|
|
117
|
+
let refreshedThisCall = false;
|
|
118
|
+
if (needsRefresh(account)) {
|
|
119
|
+
refreshedThisCall = true;
|
|
120
|
+
const failure = await runRefresh();
|
|
121
|
+
if (failure) {
|
|
122
|
+
return failure;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let { response, networkError } = await requestUsage(account.token);
|
|
126
|
+
if (response?.status === 401 && account.refreshToken && !refreshedThisCall) {
|
|
127
|
+
const failure = await runRefresh();
|
|
128
|
+
if (failure) {
|
|
129
|
+
return failure;
|
|
130
|
+
}
|
|
131
|
+
({ response, networkError } = await requestUsage(account.token));
|
|
132
|
+
}
|
|
133
|
+
if (!response) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: "network",
|
|
137
|
+
error: networkError ?? "network failure",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const body = await response.text().catch(() => "");
|
|
142
|
+
logger.debug(`[account-usage] usage fetch failed for ${account.label}`, {
|
|
143
|
+
status: response.status,
|
|
144
|
+
body: body.slice(0, 300),
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: response.status === 401 || response.status === 403 ? "auth" : "http",
|
|
149
|
+
error: `usage endpoint returned HTTP ${response.status}`,
|
|
150
|
+
status: response.status,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const usage = (await response.json());
|
|
155
|
+
return { ok: true, usage };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
reason: "parse",
|
|
161
|
+
error: err instanceof Error ? err.message : String(err),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Normalization (pure CPU — unit-testable, no I/O)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
const REJECTED_SEVERITIES = new Set([
|
|
169
|
+
"rejected",
|
|
170
|
+
"exceeded",
|
|
171
|
+
"over_limit",
|
|
172
|
+
"blocked",
|
|
173
|
+
]);
|
|
174
|
+
/**
|
|
175
|
+
* "rejected" iff the window is fully used or the provider severity marks it
|
|
176
|
+
* exhausted; anything unknown stays "allowed". Conservative on purpose: a
|
|
177
|
+
* wrong "rejected" would park an account, a wrong "allowed" just delays
|
|
178
|
+
* parking until the passive capture or the reset. Raw severity is preserved
|
|
179
|
+
* on the window so nothing is lost.
|
|
180
|
+
*/
|
|
181
|
+
function deriveWindowStatus(percent, severity) {
|
|
182
|
+
const sev = severity?.trim().toLowerCase();
|
|
183
|
+
if ((typeof percent === "number" && percent >= 100) ||
|
|
184
|
+
(sev !== undefined && REJECTED_SEVERITIES.has(sev))) {
|
|
185
|
+
return "rejected";
|
|
186
|
+
}
|
|
187
|
+
return "allowed";
|
|
188
|
+
}
|
|
189
|
+
/** ISO-8601 → unix seconds; 0 when absent/unparseable (matches headers). */
|
|
190
|
+
function isoToEpochSeconds(value) {
|
|
191
|
+
if (!value) {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const ms = Date.parse(value);
|
|
195
|
+
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000);
|
|
196
|
+
}
|
|
197
|
+
function toFraction(percent) {
|
|
198
|
+
return typeof percent === "number" && Number.isFinite(percent)
|
|
199
|
+
? percent / 100
|
|
200
|
+
: undefined;
|
|
201
|
+
}
|
|
202
|
+
function mapUsageLimit(entry) {
|
|
203
|
+
const window = {
|
|
204
|
+
kind: entry.kind ?? "unknown",
|
|
205
|
+
used: toFraction(entry.percent) ?? 0,
|
|
206
|
+
status: deriveWindowStatus(entry.percent, entry.severity),
|
|
207
|
+
resetsAt: isoToEpochSeconds(entry.resets_at),
|
|
208
|
+
};
|
|
209
|
+
if (entry.group !== undefined) {
|
|
210
|
+
window.group = entry.group;
|
|
211
|
+
}
|
|
212
|
+
if (entry.severity !== null && entry.severity !== undefined) {
|
|
213
|
+
window.severity = entry.severity;
|
|
214
|
+
}
|
|
215
|
+
if (entry.is_active !== null && entry.is_active !== undefined) {
|
|
216
|
+
window.isActive = entry.is_active;
|
|
217
|
+
}
|
|
218
|
+
const scopeModel = entry.scope?.model?.display_name;
|
|
219
|
+
if (scopeModel) {
|
|
220
|
+
window.scopeModel = scopeModel;
|
|
221
|
+
}
|
|
222
|
+
const scopeSurface = entry.scope?.surface;
|
|
223
|
+
if (scopeSurface) {
|
|
224
|
+
window.scopeSurface = scopeSurface;
|
|
225
|
+
}
|
|
226
|
+
return window;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Normalize a usage-endpoint payload into the `AccountQuota` shape used by
|
|
230
|
+
* the passive header path, so a refresh writes through the exact same
|
|
231
|
+
* save/merge/cooldown chain.
|
|
232
|
+
*
|
|
233
|
+
* `prior` supplies fields the usage payload cannot express (fallback and
|
|
234
|
+
* upgrade-path signals) so `isQuotaOverageAvailable` behaves identically
|
|
235
|
+
* before and after a refresh. `unifiedStatus` is deliberately never set:
|
|
236
|
+
* fabricating it would let the reconcile path treat a refresh like a
|
|
237
|
+
* unified-rejected 429.
|
|
238
|
+
*/
|
|
239
|
+
export function usageToQuota(usage, opts) {
|
|
240
|
+
const limits = Array.isArray(usage.limits) ? usage.limits : [];
|
|
241
|
+
if (!usage.five_hour && !usage.seven_day && limits.length === 0) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const { now, prior } = opts;
|
|
245
|
+
const windows = limits.map(mapUsageLimit);
|
|
246
|
+
const sessionLimit = limits.find((entry) => entry.kind === "session");
|
|
247
|
+
const weeklyLimit = limits.find((entry) => entry.kind === "weekly_all");
|
|
248
|
+
const sessionPct = usage.five_hour?.utilization ?? sessionLimit?.percent ?? undefined;
|
|
249
|
+
const weeklyPct = usage.seven_day?.utilization ?? weeklyLimit?.percent ?? undefined;
|
|
250
|
+
// A refresh must never replace a known reset with 0: the payload omits
|
|
251
|
+
// `resets_at` for untouched windows, and a zero reset would stop
|
|
252
|
+
// reconcileCooldownFromQuota from parking a rejected window and make the
|
|
253
|
+
// routing metrics treat it as non-ticking. A stale prior reset degrades
|
|
254
|
+
// gracefully (past resets are ignored / freshened downstream).
|
|
255
|
+
const sessionResetAt = isoToEpochSeconds(usage.five_hour?.resets_at) ||
|
|
256
|
+
isoToEpochSeconds(sessionLimit?.resets_at) ||
|
|
257
|
+
(prior?.sessionResetAt ?? 0);
|
|
258
|
+
const weeklyResetAt = isoToEpochSeconds(usage.seven_day?.resets_at) ||
|
|
259
|
+
isoToEpochSeconds(weeklyLimit?.resets_at) ||
|
|
260
|
+
(prior?.weeklyResetAt ?? 0);
|
|
261
|
+
const overageEnabled = usage.extra_usage?.is_enabled;
|
|
262
|
+
const overageStatus = overageEnabled === true
|
|
263
|
+
? "allowed"
|
|
264
|
+
: overageEnabled === false
|
|
265
|
+
? "rejected"
|
|
266
|
+
: (prior?.overageStatus ?? "unknown");
|
|
267
|
+
const quota = {
|
|
268
|
+
sessionUsed: toFraction(sessionPct) ?? prior?.sessionUsed ?? 0,
|
|
269
|
+
sessionStatus: deriveWindowStatus(sessionPct, sessionLimit?.severity),
|
|
270
|
+
sessionResetAt,
|
|
271
|
+
weeklyUsed: toFraction(weeklyPct) ?? prior?.weeklyUsed ?? 0,
|
|
272
|
+
weeklyStatus: deriveWindowStatus(weeklyPct, weeklyLimit?.severity),
|
|
273
|
+
weeklyResetAt,
|
|
274
|
+
fallbackPercentage: prior?.fallbackPercentage ?? 0,
|
|
275
|
+
overageStatus,
|
|
276
|
+
lastUpdated: now,
|
|
277
|
+
windows,
|
|
278
|
+
windowsUpdatedAt: now,
|
|
279
|
+
source: "usage-api",
|
|
280
|
+
};
|
|
281
|
+
if (prior?.fallbackStatus !== undefined) {
|
|
282
|
+
quota.fallbackStatus = prior.fallbackStatus;
|
|
283
|
+
}
|
|
284
|
+
if (prior?.upgradePaths !== undefined) {
|
|
285
|
+
quota.upgradePaths = prior.upgradePaths;
|
|
286
|
+
}
|
|
287
|
+
return quota;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=accountUsage.js.map
|
|
@@ -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;
|