@juspay/neurolink 10.7.0 → 10.8.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/adapters/replicate/predictionLifecycle.d.ts +1 -1
  3. package/dist/adapters/replicate/predictionLifecycle.js +50 -45
  4. package/dist/agent/directTools.d.ts +2 -2
  5. package/dist/browser/neurolink.min.js +373 -373
  6. package/dist/cli/commands/proxyAnalyze.js +1 -1
  7. package/dist/core/baseProvider.d.ts +1 -0
  8. package/dist/core/baseProvider.js +74 -11
  9. package/dist/core/modules/TelemetryHandler.d.ts +5 -1
  10. package/dist/core/modules/TelemetryHandler.js +20 -0
  11. package/dist/lib/adapters/replicate/predictionLifecycle.d.ts +1 -1
  12. package/dist/lib/adapters/replicate/predictionLifecycle.js +50 -45
  13. package/dist/lib/core/baseProvider.d.ts +1 -0
  14. package/dist/lib/core/baseProvider.js +74 -11
  15. package/dist/lib/core/modules/TelemetryHandler.d.ts +5 -1
  16. package/dist/lib/core/modules/TelemetryHandler.js +20 -0
  17. package/dist/lib/mcp/httpRateLimiter.js +14 -21
  18. package/dist/lib/proxy/proxyAnalysis.js +27 -9
  19. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +16 -2
  20. package/dist/lib/server/routes/claudeProxyRoutes.js +15 -9
  21. package/dist/lib/types/generate.d.ts +16 -0
  22. package/dist/lib/types/proxy.d.ts +3 -0
  23. package/dist/lib/utils/errorHandling.d.ts +2 -0
  24. package/dist/lib/utils/errorHandling.js +2 -0
  25. package/dist/lib/utils/pdfProcessor.js +2 -2
  26. package/dist/lib/utils/providerRetry.d.ts +5 -1
  27. package/dist/lib/utils/providerRetry.js +34 -8
  28. package/dist/lib/utils/retryAfter.d.ts +7 -0
  29. package/dist/lib/utils/retryAfter.js +44 -0
  30. package/dist/mcp/httpRateLimiter.js +14 -21
  31. package/dist/proxy/proxyAnalysis.js +27 -9
  32. package/dist/server/routes/claudeProxyRoutes.d.ts +16 -2
  33. package/dist/server/routes/claudeProxyRoutes.js +15 -9
  34. package/dist/types/generate.d.ts +16 -0
  35. package/dist/types/proxy.d.ts +3 -0
  36. package/dist/utils/errorHandling.d.ts +2 -0
  37. package/dist/utils/errorHandling.js +2 -0
  38. package/dist/utils/pdfProcessor.js +2 -2
  39. package/dist/utils/providerRetry.d.ts +5 -1
  40. package/dist/utils/providerRetry.js +34 -8
  41. package/dist/utils/retryAfter.d.ts +7 -0
  42. package/dist/utils/retryAfter.js +43 -0
  43. package/package.json +3 -2
@@ -20,6 +20,8 @@ const ROUTING_MODES = new Set(PROXY_ACCOUNT_ROUTING_MODES);
20
20
  const ROUTING_REASONS = new Set(PROXY_ACCOUNT_ROUTING_REASONS);
21
21
  const ROUTING_ACCOUNT_TYPES = new Set(PROXY_ACCOUNT_TYPES);
22
22
  const COOLING_REASONS = new Set(ACCOUNT_COOLING_REASONS);
23
+ const MAX_RETAINED_ROUTING_RECORDS = 200;
24
+ const MAX_ROUTING_RECORDS_BEFORE_COMPACTION = MAX_RETAINED_ROUTING_RECORDS * 2;
23
25
  function isContainedPath(root, candidate) {
24
26
  const candidateRelative = relative(root, candidate);
25
27
  return (candidateRelative.length > 0 &&
@@ -338,7 +340,8 @@ function summarizeRouting(finalRequests) {
338
340
  const modes = {};
339
341
  const selectionReasons = {};
340
342
  const initialAccounts = {};
341
- const records = [];
343
+ const retainedRecords = [];
344
+ let totalRecords = 0;
342
345
  let finalAccountChanges = 0;
343
346
  let finalOutsideCandidateSet = 0;
344
347
  for (const [requestId, request] of finalRequests) {
@@ -357,21 +360,36 @@ function summarizeRouting(finalRequests) {
357
360
  else if (decision.initialAccount !== request.account) {
358
361
  finalAccountChanges += 1;
359
362
  }
360
- records.push({
361
- requestId,
362
- timestamp: request.timestamp,
363
- responseStatus: request.status,
364
- finalAccount: request.account,
365
- finalAccountType: request.accountType,
366
- decision,
363
+ totalRecords += 1;
364
+ retainedRecords.push({
365
+ record: {
366
+ requestId,
367
+ timestamp: request.timestamp,
368
+ responseStatus: request.status,
369
+ finalAccount: request.account,
370
+ finalAccountType: request.accountType,
371
+ decision,
372
+ },
373
+ timestampMs: Date.parse(request.timestamp),
374
+ sequence: totalRecords,
367
375
  });
376
+ if (retainedRecords.length > MAX_ROUTING_RECORDS_BEFORE_COMPACTION) {
377
+ retainedRecords.sort((left, right) => left.timestampMs - right.timestampMs ||
378
+ left.sequence - right.sequence);
379
+ retainedRecords.splice(0, retainedRecords.length - MAX_RETAINED_ROUTING_RECORDS);
380
+ }
368
381
  }
382
+ retainedRecords.sort((left, right) => left.timestampMs - right.timestampMs || left.sequence - right.sequence);
383
+ const records = retainedRecords
384
+ .slice(-MAX_RETAINED_ROUTING_RECORDS)
385
+ .map(({ record }) => record);
369
386
  return {
370
387
  modes,
371
388
  selectionReasons,
372
389
  initialAccounts,
373
390
  finalAccountChanges,
374
391
  finalOutsideCandidateSet,
392
+ totalRecords,
375
393
  records,
376
394
  };
377
395
  }
@@ -704,7 +722,7 @@ export async function analyzeProxyLogs(options) {
704
722
  attempts: totalAttempts > 0,
705
723
  attemptLatency: attemptLatency.length > 0,
706
724
  cacheUsage: finalSummary.cache.requestsWithUsage > 0,
707
- routingDecisions: routingSummary.records.length > 0,
725
+ routingDecisions: routingSummary.totalRecords > 0,
708
726
  },
709
727
  dataQuality: {
710
728
  linesRead,
@@ -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, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
15
+ import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
16
  /** Resolve the configured primary's stable key to its current index in the
17
17
  * request's enabledAccounts list. Returns 0 (insertion-order fallback) when
18
18
  * no key is configured or the key cannot be matched (account disabled/
@@ -85,6 +85,19 @@ declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]):
85
85
  * last resort.
86
86
  */
87
87
  declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number): ProxyPassthroughAccount[];
88
+ declare function buildRoutingDecision(args: {
89
+ accounts: ProxyPassthroughAccount[];
90
+ orderedAccounts: ProxyPassthroughAccount[];
91
+ metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
92
+ evaluatedAt: number;
93
+ strategy: "round-robin" | "fill-first";
94
+ primaryKey: string | undefined;
95
+ quotaRoutingEnabled: boolean;
96
+ quotaOrdered: boolean;
97
+ sessionSoftLimit: number;
98
+ sessionResetToleranceMs: number;
99
+ rotationOffset: number;
100
+ }): ProxyAccountRoutingDecision | undefined;
88
101
  declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>): {
89
102
  stream: ReadableStream<Uint8Array>;
90
103
  outcome: Promise<StreamTerminalOutcome>;
@@ -279,7 +292,8 @@ export declare const __testHooks: {
279
292
  getStreamFailureDetails: typeof getStreamFailureDetails;
280
293
  trackUpstreamReadableStream: typeof trackUpstreamReadableStream;
281
294
  orderAccountsByQuota: typeof orderAccountsByQuota;
282
- buildQuotaRoutingDecision: (accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number) => ProxyAccountRoutingDecision;
295
+ buildQuotaRoutingDecision: (accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number) => ProxyAccountRoutingDecision | undefined;
296
+ buildRoutingDecision: typeof buildRoutingDecision;
283
297
  resetEpochToMs: typeof resetEpochToMs;
284
298
  seedRuntimeQuotasFromDisk: typeof seedRuntimeQuotasFromDisk;
285
299
  reconcileEligibleAccountRuntimeState: typeof reconcileEligibleAccountRuntimeState;
@@ -562,16 +562,18 @@ function buildRoutingDecision(args) {
562
562
  const { accounts, orderedAccounts, metricsByKey, evaluatedAt, strategy, primaryKey, quotaRoutingEnabled, quotaOrdered, sessionSoftLimit, sessionResetToleranceMs, rotationOffset, } = args;
563
563
  const sourceIndexes = new Map(accounts.map((account, index) => [account.key, index]));
564
564
  const configuredPrimaryMatched = !!primaryKey && accounts.some((account) => account.key === primaryKey);
565
- const candidates = orderedAccounts.map((account, rank) => {
565
+ const candidates = [];
566
+ for (const account of orderedAccounts) {
566
567
  const metrics = metricsByKey.get(account.key);
567
568
  if (!metrics) {
568
- throw new Error(`Missing precomputed routing metrics for account ${account.label}`);
569
+ logger.warn(`[proxy] routing evidence omitted because metrics are missing for account=${account.label}`);
570
+ return undefined;
569
571
  }
570
- return {
572
+ candidates.push({
571
573
  account: account.label,
572
574
  accountType: account.type,
573
- sourceIndex: sourceIndexes.get(account.key) ?? rank,
574
- rank,
575
+ sourceIndex: sourceIndexes.get(account.key) ?? candidates.length,
576
+ rank: candidates.length,
575
577
  configuredPrimary: !!primaryKey && account.key === primaryKey,
576
578
  usable: metrics.usable,
577
579
  saturated: metrics.saturated,
@@ -598,8 +600,8 @@ function buildRoutingDecision(args) {
598
600
  weeklyResetAt: Number.isFinite(metrics.weeklyReset)
599
601
  ? metrics.weeklyReset
600
602
  : null,
601
- };
602
- });
603
+ });
604
+ }
603
605
  const initialAccount = orderedAccounts[0];
604
606
  let mode;
605
607
  let selectionReason;
@@ -684,7 +686,7 @@ function selectClaudeProxyAccountOrder(args) {
684
686
  accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs),
685
687
  ]));
686
688
  }
687
- setRoutingDecision(buildRoutingDecision({
689
+ const routingDecision = buildRoutingDecision({
688
690
  accounts: enabledAccounts,
689
691
  orderedAccounts,
690
692
  metricsByKey,
@@ -696,7 +698,10 @@ function selectClaudeProxyAccountOrder(args) {
696
698
  sessionSoftLimit,
697
699
  sessionResetToleranceMs,
698
700
  rotationOffset,
699
- }));
701
+ });
702
+ if (routingDecision) {
703
+ setRoutingDecision(routingDecision);
704
+ }
700
705
  return orderedAccounts;
701
706
  }
702
707
  // ---------------------------------------------------------------------------
@@ -4896,6 +4901,7 @@ export const __testHooks = {
4896
4901
  rotationOffset: 0,
4897
4902
  });
4898
4903
  },
4904
+ buildRoutingDecision,
4899
4905
  resetEpochToMs,
4900
4906
  seedRuntimeQuotasFromDisk,
4901
4907
  reconcileEligibleAccountRuntimeState,
@@ -1418,9 +1418,25 @@ export type TextGenerationResult = {
1418
1418
  /**
1419
1419
  * Enhanced result type with optional analytics/evaluation
1420
1420
  */
1421
+ export type TTSMetadata = {
1422
+ /** Whether TTS synthesis was invoked. False indicates TTS was skipped. */
1423
+ attempted: boolean;
1424
+ /** Whether TTS synthesis completed successfully. */
1425
+ success: boolean;
1426
+ /** Structured synthesis error details, present only when synthesis failed. */
1427
+ error?: {
1428
+ code: string;
1429
+ message: string;
1430
+ retriable?: boolean;
1431
+ };
1432
+ /** TTS synthesis time in milliseconds. */
1433
+ latency?: number;
1434
+ };
1421
1435
  export type EnhancedGenerateResult = GenerateResult & {
1422
1436
  analytics?: AnalyticsData;
1423
1437
  evaluation?: EvaluationData;
1438
+ /** Outcome metadata when TTS was enabled for this generation. */
1439
+ ttsMetadata?: TTSMetadata;
1424
1440
  };
1425
1441
  /**
1426
1442
  * NL-004: Model alias/deprecation configuration.
@@ -1341,6 +1341,9 @@ export type ProxyAnalysisReport = {
1341
1341
  initialAccounts: Record<string, number>;
1342
1342
  finalAccountChanges: number;
1343
1343
  finalOutsideCandidateSet: number;
1344
+ /** Number of routing decisions aggregated before sampling records. */
1345
+ totalRecords: number;
1346
+ /** Most recent bounded sample retained for offline inspection. */
1344
1347
  records: ProxyAnalysisRoutingRecord[];
1345
1348
  };
1346
1349
  accounts: ProxyAnalysisAccount[];
@@ -70,6 +70,7 @@ export declare class NeuroLinkError extends Error {
70
70
  readonly category: ErrorCategory;
71
71
  readonly severity: ErrorSeverity;
72
72
  readonly retriable: boolean;
73
+ readonly retryAfterMs?: number;
73
74
  readonly context: Record<string, unknown>;
74
75
  readonly timestamp: Date;
75
76
  readonly toolName?: string;
@@ -80,6 +81,7 @@ export declare class NeuroLinkError extends Error {
80
81
  category: ErrorCategory;
81
82
  severity: ErrorSeverity;
82
83
  retriable: boolean;
84
+ retryAfterMs?: number;
83
85
  context?: Record<string, unknown>;
84
86
  originalError?: Error;
85
87
  toolName?: string;
@@ -87,6 +87,7 @@ export class NeuroLinkError extends Error {
87
87
  category;
88
88
  severity;
89
89
  retriable;
90
+ retryAfterMs;
90
91
  context;
91
92
  timestamp;
92
93
  toolName;
@@ -98,6 +99,7 @@ export class NeuroLinkError extends Error {
98
99
  this.category = options.category;
99
100
  this.severity = options.severity;
100
101
  this.retriable = options.retriable;
102
+ this.retryAfterMs = options.retryAfterMs;
101
103
  this.context = options.context || {};
102
104
  this.timestamp = new Date();
103
105
  this.toolName = options.toolName;
@@ -91,14 +91,14 @@ const PDF_PROVIDER_CONFIGS = {
91
91
  litellm: {
92
92
  maxSizeMB: 10,
93
93
  maxPages: 100,
94
- supportsNative: true,
94
+ supportsNative: false, // LiteLLM is a proxy — underlying model may not support native PDF; default to safe text extraction
95
95
  requiresCitations: false,
96
96
  apiType: "files-api",
97
97
  },
98
98
  "openai-compatible": {
99
99
  maxSizeMB: 10,
100
100
  maxPages: 100,
101
- supportsNative: false, // LiteLLM is a proxy — underlying model may not support native PDF; default to safe text extraction
101
+ supportsNative: false,
102
102
  requiresCitations: false,
103
103
  apiType: "files-api",
104
104
  },
@@ -18,6 +18,10 @@ import { type Span } from "@opentelemetry/api";
18
18
  export declare const MAX_PROVIDER_RETRIES = 2;
19
19
  /** Base delay in ms for exponential backoff between retries. */
20
20
  export declare const BASE_RETRY_DELAY_MS = 1000;
21
+ /** Minimum delay in ms when a retryable response provides no retry timing. */
22
+ export declare const NO_HINT_FLOOR_MS = 10000;
23
+ /** Maximum server-requested retry delay honored by provider retries. */
24
+ export declare const MAX_RETRY_AFTER_MS = 120000;
21
25
  /**
22
26
  * Check whether an error thrown by the AI SDK is retryable.
23
27
  *
@@ -38,4 +42,4 @@ export declare function getErrorStatusCode(error: unknown): number | undefined;
38
42
  * @param label - A human-readable label for log messages (e.g. "generateText", "streamText")
39
43
  * @returns The result of the operation
40
44
  */
41
- export declare function withProviderRetry<T>(operation: () => Promise<T>, span: Span, label: string): Promise<T>;
45
+ export declare function withProviderRetry<T>(operation: () => Promise<T>, span: Span | undefined, label: string, sleep?: (delayMs: number) => Promise<void>): Promise<T>;
@@ -14,12 +14,19 @@
14
14
  * @module utils/providerRetry
15
15
  */
16
16
  import {} from "@opentelemetry/api";
17
+ import { NeuroLinkError } from "./errorHandling.js";
17
18
  import { logger } from "./logger.js";
18
19
  import { APICallError } from "./generationErrors.js";
20
+ import { parseRetryAfterMs } from "./retryAfter.js";
19
21
  /** Maximum number of retry attempts after the initial call (total = 1 + MAX_PROVIDER_RETRIES). */
20
22
  export const MAX_PROVIDER_RETRIES = 2;
21
23
  /** Base delay in ms for exponential backoff between retries. */
22
24
  export const BASE_RETRY_DELAY_MS = 1000;
25
+ /** Minimum delay in ms when a retryable response provides no retry timing. */
26
+ export const NO_HINT_FLOOR_MS = 10_000;
27
+ /** Maximum server-requested retry delay honored by provider retries. */
28
+ export const MAX_RETRY_AFTER_MS = 120_000;
29
+ const sleepWithTimeout = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
23
30
  /**
24
31
  * Check whether an error thrown by the AI SDK is retryable.
25
32
  *
@@ -32,6 +39,9 @@ export function isRetryableProviderError(error) {
32
39
  if (APICallError.isInstance(error)) {
33
40
  return error.isRetryable;
34
41
  }
42
+ if (error instanceof NeuroLinkError) {
43
+ return error.retriable;
44
+ }
35
45
  // Fallback: duck-type for status codes on errors that aren't APICallError
36
46
  if (error && typeof error === "object" && "statusCode" in error) {
37
47
  const statusCode = error.statusCode;
@@ -51,6 +61,18 @@ export function getErrorStatusCode(error) {
51
61
  }
52
62
  return undefined;
53
63
  }
64
+ function getRetryAfterMs(error) {
65
+ if (APICallError.isInstance(error) && error.responseHeaders) {
66
+ const parsedDelay = parseRetryAfterMs(error.responseHeaders);
67
+ if (parsedDelay !== undefined) {
68
+ return parsedDelay;
69
+ }
70
+ }
71
+ if (error instanceof NeuroLinkError && error.retryAfterMs !== undefined) {
72
+ return error.retryAfterMs;
73
+ }
74
+ return undefined;
75
+ }
54
76
  /**
55
77
  * Execute a provider call with instrumented retry logic.
56
78
  *
@@ -59,12 +81,12 @@ export function getErrorStatusCode(error) {
59
81
  * @param label - A human-readable label for log messages (e.g. "generateText", "streamText")
60
82
  * @returns The result of the operation
61
83
  */
62
- export async function withProviderRetry(operation, span, label) {
84
+ export async function withProviderRetry(operation, span, label, sleep = sleepWithTimeout) {
63
85
  for (let attempt = 0; attempt <= MAX_PROVIDER_RETRIES; attempt++) {
64
86
  try {
65
87
  const result = await operation();
66
88
  // Record how many attempts it took on the span
67
- span.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
89
+ span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
68
90
  if (attempt > 0) {
69
91
  logger.info(`[providerRetry] ${label} succeeded after ${attempt + 1} attempts`);
70
92
  }
@@ -76,9 +98,9 @@ export async function withProviderRetry(operation, span, label) {
76
98
  const errorMessage = error instanceof Error ? error.message : String(error);
77
99
  if (!retryable || attempt === MAX_PROVIDER_RETRIES) {
78
100
  // Record failure details before re-throwing
79
- span.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
101
+ span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
80
102
  if (attempt > 0) {
81
- span.setAttribute("gen_ai.provider.retries_exhausted", true);
103
+ span?.setAttribute("gen_ai.provider.retries_exhausted", true);
82
104
  }
83
105
  logger.warn(`[providerRetry] ${label} failed (non-retryable or retries exhausted)`, {
84
106
  attempt: attempt + 1,
@@ -88,10 +110,14 @@ export async function withProviderRetry(operation, span, label) {
88
110
  });
89
111
  throw error;
90
112
  }
91
- // Calculate exponential backoff delay
92
- const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
113
+ const retryAfterMs = getRetryAfterMs(error);
114
+ const boundedRetryAfterMs = retryAfterMs === undefined
115
+ ? undefined
116
+ : Math.min(MAX_RETRY_AFTER_MS, Math.max(0, retryAfterMs));
117
+ const delay = boundedRetryAfterMs ??
118
+ Math.max(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), NO_HINT_FLOOR_MS);
93
119
  // Record retry event on the OTel span
94
- span.addEvent("gen_ai.provider.retry", {
120
+ span?.addEvent("gen_ai.provider.retry", {
95
121
  "retry.attempt": attempt + 1,
96
122
  "retry.delay_ms": delay,
97
123
  ...(statusCode !== undefined && { "retry.status_code": statusCode }),
@@ -104,7 +130,7 @@ export async function withProviderRetry(operation, span, label) {
104
130
  statusCode,
105
131
  error: errorMessage,
106
132
  });
107
- await new Promise((r) => setTimeout(r, delay));
133
+ await sleep(delay);
108
134
  }
109
135
  }
110
136
  // This should never be reached due to the throw inside the loop,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Parse standard and commonly used rate-limit response headers.
3
+ *
4
+ * @returns The requested delay in milliseconds, or undefined when the
5
+ * response does not provide a usable rate-limit delay.
6
+ */
7
+ export declare function parseRetryAfterMs(headers: Pick<Headers, "get"> | Readonly<Record<string, string>>): number | undefined;
@@ -0,0 +1,44 @@
1
+ function getHeader(headers, headerName) {
2
+ if ("get" in headers && typeof headers.get === "function") {
3
+ return headers.get(headerName);
4
+ }
5
+ const normalizedHeaderName = headerName.toLowerCase();
6
+ for (const [name, value] of Object.entries(headers)) {
7
+ if (name.toLowerCase() === normalizedHeaderName) {
8
+ return value;
9
+ }
10
+ }
11
+ return null;
12
+ }
13
+ /**
14
+ * Parse standard and commonly used rate-limit response headers.
15
+ *
16
+ * @returns The requested delay in milliseconds, or undefined when the
17
+ * response does not provide a usable rate-limit delay.
18
+ */
19
+ export function parseRetryAfterMs(headers) {
20
+ const retryAfter = getHeader(headers, "Retry-After");
21
+ if (retryAfter) {
22
+ const seconds = parseInt(retryAfter, 10);
23
+ if (!Number.isNaN(seconds)) {
24
+ return Math.max(0, seconds * 1000);
25
+ }
26
+ const retryDate = new Date(retryAfter);
27
+ if (!Number.isNaN(retryDate.getTime())) {
28
+ return Math.max(0, retryDate.getTime() - Date.now());
29
+ }
30
+ }
31
+ const rateLimitReset = getHeader(headers, "X-RateLimit-Reset");
32
+ if (rateLimitReset) {
33
+ const resetTimestamp = parseInt(rateLimitReset, 10);
34
+ if (!Number.isNaN(resetTimestamp)) {
35
+ const resetTime = resetTimestamp > 1e12 ? resetTimestamp : resetTimestamp * 1000;
36
+ return Math.max(0, resetTime - Date.now());
37
+ }
38
+ }
39
+ if (getHeader(headers, "X-RateLimit-Remaining") === "0") {
40
+ return 1000;
41
+ }
42
+ return undefined;
43
+ }
44
+ //# sourceMappingURL=retryAfter.js.map
@@ -4,6 +4,7 @@
4
4
  * Provides fault tolerance and prevents server overload
5
5
  */
6
6
  import { mcpLogger } from "../utils/logger.js";
7
+ import { parseRetryAfterMs } from "../utils/retryAfter.js";
7
8
  import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js";
8
9
  import { getActiveTraceContext } from "../telemetry/traceContext.js";
9
10
  /**
@@ -176,48 +177,40 @@ export class HTTPRateLimiter {
176
177
  * @returns Wait time in milliseconds, or 0 if no rate limit headers found
177
178
  */
178
179
  handleRateLimitResponse(headers) {
179
- // Check for Retry-After header (standard HTTP 429 response)
180
+ const parsedWaitTimeMs = parseRetryAfterMs(headers);
181
+ // Keep the existing source-specific logs while delegating delay parsing.
180
182
  const retryAfter = headers.get("Retry-After");
181
- if (retryAfter) {
182
- // Retry-After can be either a number of seconds or an HTTP-date
183
+ if (retryAfter && parsedWaitTimeMs !== undefined) {
183
184
  const seconds = parseInt(retryAfter, 10);
184
185
  if (!isNaN(seconds)) {
185
- // It's a number of seconds
186
- const waitTimeMs = seconds * 1000;
187
186
  mcpLogger.info(`[HTTPRateLimiter] Server requested retry after ${seconds} seconds`);
188
- return waitTimeMs;
187
+ return parsedWaitTimeMs;
189
188
  }
190
189
  else {
191
- // Try to parse as HTTP-date
192
190
  const retryDate = new Date(retryAfter);
193
191
  if (!isNaN(retryDate.getTime())) {
194
- const waitTimeMs = Math.max(0, retryDate.getTime() - Date.now());
195
- mcpLogger.info(`[HTTPRateLimiter] Server requested retry at ${retryDate.toISOString()} (${waitTimeMs}ms)`);
196
- return waitTimeMs;
192
+ mcpLogger.info(`[HTTPRateLimiter] Server requested retry at ${retryDate.toISOString()} (${parsedWaitTimeMs}ms)`);
193
+ return parsedWaitTimeMs;
197
194
  }
198
195
  }
199
196
  }
200
197
  // Check for X-RateLimit-Reset header (common non-standard header)
201
198
  const rateLimitReset = headers.get("X-RateLimit-Reset");
202
- if (rateLimitReset) {
199
+ if (rateLimitReset && parsedWaitTimeMs !== undefined) {
203
200
  const resetTimestamp = parseInt(rateLimitReset, 10);
204
201
  if (!isNaN(resetTimestamp)) {
205
- // Could be Unix timestamp (seconds) or milliseconds
206
202
  const resetTime = resetTimestamp > 1e12 ? resetTimestamp : resetTimestamp * 1000;
207
- const waitTimeMs = Math.max(0, resetTime - Date.now());
208
- mcpLogger.info(`[HTTPRateLimiter] Rate limit resets at ${new Date(resetTime).toISOString()} (${waitTimeMs}ms)`);
209
- return waitTimeMs;
203
+ mcpLogger.info(`[HTTPRateLimiter] Rate limit resets at ${new Date(resetTime).toISOString()} (${parsedWaitTimeMs}ms)`);
204
+ return parsedWaitTimeMs;
210
205
  }
211
206
  }
212
207
  // Check for X-RateLimit-Remaining header
213
208
  const remaining = headers.get("X-RateLimit-Remaining");
214
- if (remaining === "0") {
215
- // No remaining requests, use default backoff
216
- const defaultBackoffMs = 1000;
217
- mcpLogger.info(`[HTTPRateLimiter] Rate limit exhausted, using default backoff: ${defaultBackoffMs}ms`);
218
- return defaultBackoffMs;
209
+ if (remaining === "0" && parsedWaitTimeMs !== undefined) {
210
+ mcpLogger.info(`[HTTPRateLimiter] Rate limit exhausted, using default backoff: ${parsedWaitTimeMs}ms`);
211
+ return parsedWaitTimeMs;
219
212
  }
220
- return 0;
213
+ return parsedWaitTimeMs ?? 0;
221
214
  }
222
215
  /**
223
216
  * Get the number of remaining tokens
@@ -20,6 +20,8 @@ const ROUTING_MODES = new Set(PROXY_ACCOUNT_ROUTING_MODES);
20
20
  const ROUTING_REASONS = new Set(PROXY_ACCOUNT_ROUTING_REASONS);
21
21
  const ROUTING_ACCOUNT_TYPES = new Set(PROXY_ACCOUNT_TYPES);
22
22
  const COOLING_REASONS = new Set(ACCOUNT_COOLING_REASONS);
23
+ const MAX_RETAINED_ROUTING_RECORDS = 200;
24
+ const MAX_ROUTING_RECORDS_BEFORE_COMPACTION = MAX_RETAINED_ROUTING_RECORDS * 2;
23
25
  function isContainedPath(root, candidate) {
24
26
  const candidateRelative = relative(root, candidate);
25
27
  return (candidateRelative.length > 0 &&
@@ -338,7 +340,8 @@ function summarizeRouting(finalRequests) {
338
340
  const modes = {};
339
341
  const selectionReasons = {};
340
342
  const initialAccounts = {};
341
- const records = [];
343
+ const retainedRecords = [];
344
+ let totalRecords = 0;
342
345
  let finalAccountChanges = 0;
343
346
  let finalOutsideCandidateSet = 0;
344
347
  for (const [requestId, request] of finalRequests) {
@@ -357,21 +360,36 @@ function summarizeRouting(finalRequests) {
357
360
  else if (decision.initialAccount !== request.account) {
358
361
  finalAccountChanges += 1;
359
362
  }
360
- records.push({
361
- requestId,
362
- timestamp: request.timestamp,
363
- responseStatus: request.status,
364
- finalAccount: request.account,
365
- finalAccountType: request.accountType,
366
- decision,
363
+ totalRecords += 1;
364
+ retainedRecords.push({
365
+ record: {
366
+ requestId,
367
+ timestamp: request.timestamp,
368
+ responseStatus: request.status,
369
+ finalAccount: request.account,
370
+ finalAccountType: request.accountType,
371
+ decision,
372
+ },
373
+ timestampMs: Date.parse(request.timestamp),
374
+ sequence: totalRecords,
367
375
  });
376
+ if (retainedRecords.length > MAX_ROUTING_RECORDS_BEFORE_COMPACTION) {
377
+ retainedRecords.sort((left, right) => left.timestampMs - right.timestampMs ||
378
+ left.sequence - right.sequence);
379
+ retainedRecords.splice(0, retainedRecords.length - MAX_RETAINED_ROUTING_RECORDS);
380
+ }
368
381
  }
382
+ retainedRecords.sort((left, right) => left.timestampMs - right.timestampMs || left.sequence - right.sequence);
383
+ const records = retainedRecords
384
+ .slice(-MAX_RETAINED_ROUTING_RECORDS)
385
+ .map(({ record }) => record);
369
386
  return {
370
387
  modes,
371
388
  selectionReasons,
372
389
  initialAccounts,
373
390
  finalAccountChanges,
374
391
  finalOutsideCandidateSet,
392
+ totalRecords,
375
393
  records,
376
394
  };
377
395
  }
@@ -704,7 +722,7 @@ export async function analyzeProxyLogs(options) {
704
722
  attempts: totalAttempts > 0,
705
723
  attemptLatency: attemptLatency.length > 0,
706
724
  cacheUsage: finalSummary.cache.requestsWithUsage > 0,
707
- routingDecisions: routingSummary.records.length > 0,
725
+ routingDecisions: routingSummary.totalRecords > 0,
708
726
  },
709
727
  dataQuality: {
710
728
  linesRead,
@@ -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, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
15
+ import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
16
  /** Resolve the configured primary's stable key to its current index in the
17
17
  * request's enabledAccounts list. Returns 0 (insertion-order fallback) when
18
18
  * no key is configured or the key cannot be matched (account disabled/
@@ -85,6 +85,19 @@ declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]):
85
85
  * last resort.
86
86
  */
87
87
  declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number): ProxyPassthroughAccount[];
88
+ declare function buildRoutingDecision(args: {
89
+ accounts: ProxyPassthroughAccount[];
90
+ orderedAccounts: ProxyPassthroughAccount[];
91
+ metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
92
+ evaluatedAt: number;
93
+ strategy: "round-robin" | "fill-first";
94
+ primaryKey: string | undefined;
95
+ quotaRoutingEnabled: boolean;
96
+ quotaOrdered: boolean;
97
+ sessionSoftLimit: number;
98
+ sessionResetToleranceMs: number;
99
+ rotationOffset: number;
100
+ }): ProxyAccountRoutingDecision | undefined;
88
101
  declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>): {
89
102
  stream: ReadableStream<Uint8Array>;
90
103
  outcome: Promise<StreamTerminalOutcome>;
@@ -279,7 +292,8 @@ export declare const __testHooks: {
279
292
  getStreamFailureDetails: typeof getStreamFailureDetails;
280
293
  trackUpstreamReadableStream: typeof trackUpstreamReadableStream;
281
294
  orderAccountsByQuota: typeof orderAccountsByQuota;
282
- buildQuotaRoutingDecision: (accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number) => ProxyAccountRoutingDecision;
295
+ buildQuotaRoutingDecision: (accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number) => ProxyAccountRoutingDecision | undefined;
296
+ buildRoutingDecision: typeof buildRoutingDecision;
283
297
  resetEpochToMs: typeof resetEpochToMs;
284
298
  seedRuntimeQuotasFromDisk: typeof seedRuntimeQuotasFromDisk;
285
299
  reconcileEligibleAccountRuntimeState: typeof reconcileEligibleAccountRuntimeState;