@juspay/neurolink 10.9.1 → 10.10.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.
@@ -11,6 +11,7 @@ import { streamAnalyticsCollector } from "../../core/streamAnalytics.js";
11
11
  import { getModelCapabilities, getRecommendedModelForTier, isModelAvailableForTier, } from "../../models/anthropicModels.js";
12
12
  import { createOAuthFetch } from "../../proxy/oauthFetch.js";
13
13
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
14
+ import { getCapturedLimitSnapshot, getCapturedResponseHeaders, logClaudeLimitSnapshot, runInLimitCaptureScope, setLimitSpanAttributes, withLimitCapture, wrapFetchWithLimitCapture, } from "./rateLimitCapture.js";
14
15
  import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
15
16
  import { logger } from "../../utils/logger.js";
16
17
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
@@ -166,35 +167,10 @@ const detectAuthMethod = (oauthToken) => {
166
167
  });
167
168
  return method;
168
169
  };
169
- /**
170
- * Parse rate limit information from Anthropic API response headers.
171
- * @param headers - Response headers from Anthropic API
172
- * @returns Parsed rate limit information
173
- */
174
- const parseRateLimitHeaders = (headers) => {
175
- const getHeader = (name) => {
176
- if (headers instanceof Headers) {
177
- return headers.get(name);
178
- }
179
- return headers[name] || headers[name.toLowerCase()] || null;
180
- };
181
- const parseNumber = (value) => {
182
- if (!value) {
183
- return undefined;
184
- }
185
- const num = parseInt(value, 10);
186
- return isNaN(num) ? undefined : num;
187
- };
188
- return {
189
- requestsLimit: parseNumber(getHeader("anthropic-ratelimit-requests-limit")),
190
- requestsRemaining: parseNumber(getHeader("anthropic-ratelimit-requests-remaining")),
191
- requestsReset: getHeader("anthropic-ratelimit-requests-reset") || undefined,
192
- tokensLimit: parseNumber(getHeader("anthropic-ratelimit-tokens-limit")),
193
- tokensRemaining: parseNumber(getHeader("anthropic-ratelimit-tokens-remaining")),
194
- tokensReset: getHeader("anthropic-ratelimit-tokens-reset") || undefined,
195
- retryAfter: parseNumber(getHeader("retry-after")),
196
- };
197
- };
170
+ // Rate-limit header parsing lives in `rateLimitCapture.ts`, which sees the raw
171
+ // fetch Response and understands both header families (unified subscription
172
+ // windows and the legacy per-tier counters). The module-private copy that used
173
+ // to sit here parsed only the legacy family and had no callers.
198
174
  // ───────────────────────────────────────────────────────────────────────────
199
175
  // Native Messages-API conversion helpers (NeuroLink/V3 shapes → Anthropic)
200
176
  // ───────────────────────────────────────────────────────────────────────────
@@ -590,7 +566,10 @@ export class AnthropicProvider extends BaseProvider {
590
566
  client = new Anthropic({
591
567
  apiKey: "oauth-authenticated", // Placeholder, actual auth is in fetch wrapper
592
568
  // Note: No headers passed - fetch wrapper sets oauth-2025-04-20 beta header
593
- fetch: oauthFetch,
569
+ // Limit capture wraps the OAuth fetch so subscription quota headers
570
+ // (anthropic-ratelimit-unified-*) are recorded on every request —
571
+ // streaming and non-streaming alike.
572
+ fetch: wrapFetchWithLimitCapture(oauthFetch),
594
573
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
595
574
  // The SDK's built-in retry honors Retry-After hints without any
596
575
  // upper bound (a 429 with retry-after: 8549 sleeps 2.4h per retry,
@@ -642,7 +621,10 @@ export class AnthropicProvider extends BaseProvider {
642
621
  apiKey: apiKeyToUse,
643
622
  defaultHeaders: headers,
644
623
  ...(normalizedBaseURL && { baseURL: normalizedBaseURL }),
645
- fetch: createProxyFetch(),
624
+ // Same capture as the OAuth branch: works for direct API-key traffic
625
+ // (legacy requests/tokens counters) and for the NeuroLink Claude proxy
626
+ // (verbatim unified quota plus x-neurolink-* account/pool state).
627
+ fetch: wrapFetchWithLimitCapture(createProxyFetch()),
646
628
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
647
629
  // See the OAuth-path client above: unbounded Retry-After sleeps in
648
630
  // the SDK's retry loop must never stall fallback orchestration.
@@ -971,24 +953,22 @@ export class AnthropicProvider extends BaseProvider {
971
953
  return this.lastResponseMetadata;
972
954
  }
973
955
  /**
974
- * Update response metadata from API response headers.
975
- * This should be called after each API request to track rate limits.
976
- * @param headers - Response headers from the API
977
- * @param requestId - Optional request ID
956
+ * Update response metadata from a captured limit snapshot.
957
+ *
958
+ * Takes already-parsed rate-limit info rather than raw headers: parsing now
959
+ * lives in `rateLimitCapture`, which is the only layer that sees the raw
960
+ * response and understands both header families (unified subscription
961
+ * windows and legacy per-tier counters).
962
+ *
963
+ * @param rateLimit - Parsed rate-limit figures
964
+ * @param requestId - Optional Anthropic request ID
965
+ * @param usageUpdate - Optional token counts to fold into usage tracking
978
966
  */
979
- updateResponseMetadata(headers, requestId, usageUpdate) {
967
+ updateResponseMetadata(rateLimit, requestId, usageUpdate) {
980
968
  this.lastResponseMetadata = {
981
- rateLimit: parseRateLimitHeaders(headers),
982
- requestId: requestId ||
983
- (headers instanceof Headers
984
- ? headers.get("x-request-id") || undefined
985
- : headers["x-request-id"]),
986
- serverTiming: headers instanceof Headers
987
- ? headers.get("server-timing") || undefined
988
- : headers["server-timing"],
969
+ rateLimit,
970
+ ...(requestId ? { requestId } : {}),
989
971
  };
990
- // Update usage tracking
991
- const rateLimit = this.lastResponseMetadata.rateLimit;
992
972
  if (this.usageInfo) {
993
973
  this.usageInfo.requestCount++;
994
974
  this.usageInfo.messagesUsed++;
@@ -1254,7 +1234,10 @@ export class AnthropicProvider extends BaseProvider {
1254
1234
  response: {
1255
1235
  id: response.id,
1256
1236
  modelId: response.model,
1257
- headers: {},
1237
+ // Real response headers, captured by the fetch wrapper. This used
1238
+ // to be a hardcoded `{}`, which silently discarded every
1239
+ // rate-limit and quota header Anthropic returns.
1240
+ headers: getCapturedResponseHeaders() ?? {},
1258
1241
  body: response,
1259
1242
  },
1260
1243
  };
@@ -1305,9 +1288,41 @@ export class AnthropicProvider extends BaseProvider {
1305
1288
  */
1306
1289
  async generate(optionsOrPrompt, analysisSchema) {
1307
1290
  await this.refreshAuthIfNeeded();
1308
- return super.generate(optionsOrPrompt, analysisSchema);
1291
+ // Open a per-request capture scope around the whole turn. Scoping here
1292
+ // rather than on the instance is what makes it concurrency-safe: several
1293
+ // generate() calls can be in flight on one provider instance, and an
1294
+ // instance field would attribute one call's limits to another.
1295
+ const { result, snapshot } = await withLimitCapture(() => super.generate(optionsOrPrompt, analysisSchema));
1296
+ if (result && snapshot) {
1297
+ this.recordLimitSnapshot(snapshot);
1298
+ result.limits = snapshot;
1299
+ if (result.analytics) {
1300
+ result.analytics.limits = snapshot;
1301
+ }
1302
+ }
1303
+ return result;
1304
+ }
1305
+ /**
1306
+ * Fold a captured snapshot into the provider's usage bookkeeping and log it.
1307
+ *
1308
+ * `updateResponseMetadata` had no callers before this — the metadata it
1309
+ * maintains, and the public `getLastResponseMetadata()` / `getUsageInfo()`
1310
+ * that read it, were never populated by anything.
1311
+ */
1312
+ recordLimitSnapshot(snapshot) {
1313
+ this.updateResponseMetadata(snapshot.rateLimit, snapshot.requestId);
1314
+ setLimitSpanAttributes(snapshot);
1315
+ logClaudeLimitSnapshot(snapshot, this.modelName);
1316
+ }
1317
+ async executeStream(options, analysisSchema) {
1318
+ // The capture scope must outlive this call: the SSE loop keeps running in
1319
+ // the background after executeStream returns, and its per-step HTTP
1320
+ // requests are what carry the limit headers. AsyncLocalStorage.run
1321
+ // propagates into every continuation started inside, so the whole stream
1322
+ // lifetime shares one slot.
1323
+ return runInLimitCaptureScope(() => this.executeStreamInCaptureScope(options, analysisSchema));
1309
1324
  }
1310
- async executeStream(options, _analysisSchema) {
1325
+ async executeStreamInCaptureScope(options, _analysisSchema) {
1311
1326
  // Refresh OAuth token if needed before making any API request.
1312
1327
  await this.refreshAuthIfNeeded();
1313
1328
  this.validateStreamOptions(options);
@@ -1758,15 +1773,25 @@ export class AnthropicProvider extends BaseProvider {
1758
1773
  // stream consumers and session cost tracking saw no usage at all.
1759
1774
  // Chained off finishPromise so requestDuration reflects the DRAINED
1760
1775
  // stream, not the milliseconds it took to construct this result object.
1761
- analytics: finishPromise.then(() => streamAnalyticsCollector.createAnalytics(this.providerName, modelId, {
1762
- textStream: (async function* () { })(),
1763
- usage: usagePromise,
1764
- finishReason: finishPromise,
1765
- }, Date.now() - streamStartTime, {
1766
- requestId: options.requestId ??
1767
- `${this.providerName}-stream-${Date.now()}`,
1768
- streamingMode: true,
1769
- })),
1776
+ analytics: finishPromise.then(async () => {
1777
+ const analytics = await streamAnalyticsCollector.createAnalytics(this.providerName, modelId, {
1778
+ textStream: (async function* () { })(),
1779
+ usage: usagePromise,
1780
+ finishReason: finishPromise,
1781
+ }, Date.now() - streamStartTime, {
1782
+ requestId: options.requestId ??
1783
+ `${this.providerName}-stream-${Date.now()}`,
1784
+ streamingMode: true,
1785
+ });
1786
+ // Still inside the capture scope opened by executeStream, so this sees
1787
+ // the limits reported by the last upstream step of the stream.
1788
+ const snapshot = getCapturedLimitSnapshot();
1789
+ if (snapshot && analytics) {
1790
+ this.recordLimitSnapshot(snapshot);
1791
+ analytics.limits = snapshot;
1792
+ }
1793
+ return analytics;
1794
+ }),
1770
1795
  };
1771
1796
  }
1772
1797
  async isAvailable() {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Anthropic rate-limit / quota header capture.
3
+ *
4
+ * Anthropic returns limit state on the response headers of every request:
5
+ * `anthropic-ratelimit-unified-*` for subscription (OAuth) accounts,
6
+ * `anthropic-ratelimit-{requests,tokens}-*` for API-key accounts. The NeuroLink
7
+ * Claude proxy forwards those verbatim and adds `x-neurolink-*` for what only
8
+ * it knows (which account served the request, pool headroom, whether the
9
+ * numbers are live or a carried-over snapshot).
10
+ *
11
+ * None of it used to reach the SDK: `doGenerate` returned a hardcoded empty
12
+ * header bag and the streaming loop never looked. The capture point here is the
13
+ * `fetch` the Anthropic SDK is constructed with — it is invoked exactly once
14
+ * per HTTP request on BOTH the streaming and non-streaming paths, so a single
15
+ * wrapper covers everything without touching the SSE loop or switching the
16
+ * non-streaming call to `.withResponse()`.
17
+ *
18
+ * Scoping is per-request via AsyncLocalStorage rather than a field on the
19
+ * provider: a provider instance is shared across concurrent calls, so an
20
+ * instance field would race and attribute one request's limits to another.
21
+ *
22
+ * @module providers/anthropic/rateLimitCapture
23
+ */
24
+ import type { AnthropicRateLimitInfo, ClaudeLimitSnapshot } from "../../types/index.js";
25
+ /**
26
+ * Parse both Anthropic rate-limit header families into a single shape.
27
+ *
28
+ * Which family is present depends on the account type, so every field is
29
+ * optional and absence is normal rather than an error.
30
+ */
31
+ export declare function parseAnthropicLimitHeaders(headers: Headers): AnthropicRateLimitInfo;
32
+ /**
33
+ * Build a snapshot from a response, or undefined when the response carries
34
+ * neither Anthropic rate-limit headers nor NeuroLink proxy metadata.
35
+ */
36
+ export declare function buildLimitSnapshot(headers: Headers, status: number, now?: number): ClaudeLimitSnapshot | undefined;
37
+ /**
38
+ * Wrap a fetch so every response's limit headers are captured into the
39
+ * enclosing `withLimitCapture` scope. A no-op outside such a scope.
40
+ *
41
+ * Capture never alters the response and never throws — a parsing failure must
42
+ * not be able to break a request that the provider would otherwise complete.
43
+ */
44
+ export declare function wrapFetchWithLimitCapture(inner: typeof fetch): typeof fetch;
45
+ /**
46
+ * Run `body` in a capture scope and return its result alongside whatever limit
47
+ * snapshot the underlying HTTP request(s) produced.
48
+ */
49
+ export declare function withLimitCapture<T>(body: () => Promise<T>): Promise<{
50
+ result: T;
51
+ snapshot?: ClaudeLimitSnapshot;
52
+ }>;
53
+ /**
54
+ * Current scope's snapshot, if any. Lets a long-running loop (the streaming
55
+ * path) read limits mid-flight without unwinding the scope.
56
+ */
57
+ export declare function getCapturedLimitSnapshot(): ClaudeLimitSnapshot | undefined;
58
+ /** Raw headers of the most recent captured response in this scope. */
59
+ export declare function getCapturedResponseHeaders(): Record<string, string> | undefined;
60
+ /** Enter a capture scope without wrapping a single call — for streaming, where
61
+ * the scope must outlive the function that opened it. */
62
+ export declare function runInLimitCaptureScope<T>(body: () => T): T;
63
+ /**
64
+ * Attach limit state to the currently active OTel span.
65
+ *
66
+ * Uses the active span rather than threading one down from the generation
67
+ * layer: that layer is provider-agnostic and should not learn about Anthropic
68
+ * quota headers just to record them. The active span during a turn is the one
69
+ * already carrying `gen_ai.usage.*` and `neurolink.cost`, so "what did this
70
+ * cost" and "how much is left" answer from the same trace.
71
+ */
72
+ export declare function setLimitSpanAttributes(snapshot: ClaudeLimitSnapshot): void;
73
+ /**
74
+ * Emit one structured line per request describing remaining capacity.
75
+ *
76
+ * Leads with headroom ("how much is left") because that is the figure an
77
+ * operator acts on; the raw utilization stays available on the snapshot for
78
+ * anything computing against it. Escalates to WARN when the session window is
79
+ * nearly spent or the provider has already flagged the account as
80
+ * throttled/rejected.
81
+ */
82
+ export declare function logClaudeLimitSnapshot(snapshot: ClaudeLimitSnapshot, model?: string, now?: number): void;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Anthropic rate-limit / quota header capture.
3
+ *
4
+ * Anthropic returns limit state on the response headers of every request:
5
+ * `anthropic-ratelimit-unified-*` for subscription (OAuth) accounts,
6
+ * `anthropic-ratelimit-{requests,tokens}-*` for API-key accounts. The NeuroLink
7
+ * Claude proxy forwards those verbatim and adds `x-neurolink-*` for what only
8
+ * it knows (which account served the request, pool headroom, whether the
9
+ * numbers are live or a carried-over snapshot).
10
+ *
11
+ * None of it used to reach the SDK: `doGenerate` returned a hardcoded empty
12
+ * header bag and the streaming loop never looked. The capture point here is the
13
+ * `fetch` the Anthropic SDK is constructed with — it is invoked exactly once
14
+ * per HTTP request on BOTH the streaming and non-streaming paths, so a single
15
+ * wrapper covers everything without touching the SSE loop or switching the
16
+ * non-streaming call to `.withResponse()`.
17
+ *
18
+ * Scoping is per-request via AsyncLocalStorage rather than a field on the
19
+ * provider: a provider instance is shared across concurrent calls, so an
20
+ * instance field would race and attribute one request's limits to another.
21
+ *
22
+ * @module providers/anthropic/rateLimitCapture
23
+ */
24
+ import { AsyncLocalStorage } from "async_hooks";
25
+ import { trace } from "@opentelemetry/api";
26
+ import { logger } from "../../utils/logger.js";
27
+ /** Below this much session headroom (percent), log at WARN instead of INFO. */
28
+ const LOW_HEADROOM_WARN_PCT = 15;
29
+ const limitCaptureStorage = new AsyncLocalStorage();
30
+ function headerOf(headers, name) {
31
+ const value = headers.get(name);
32
+ return value === null || value === "" ? undefined : value;
33
+ }
34
+ function numberOf(headers, name) {
35
+ const raw = headerOf(headers, name);
36
+ if (raw === undefined) {
37
+ return undefined;
38
+ }
39
+ const parsed = Number(raw);
40
+ return Number.isFinite(parsed) ? parsed : undefined;
41
+ }
42
+ function intOf(headers, name) {
43
+ const raw = headerOf(headers, name);
44
+ if (raw === undefined) {
45
+ return undefined;
46
+ }
47
+ const parsed = parseInt(raw, 10);
48
+ return Number.isNaN(parsed) ? undefined : parsed;
49
+ }
50
+ /** 0.0-1.0 utilization → whole-percent remaining, clamped to [0, 100]. */
51
+ function leftPctFrom(utilization) {
52
+ if (utilization === undefined) {
53
+ return undefined;
54
+ }
55
+ return Math.max(0, Math.min(100, Math.round((1 - utilization) * 100)));
56
+ }
57
+ /**
58
+ * Parse both Anthropic rate-limit header families into a single shape.
59
+ *
60
+ * Which family is present depends on the account type, so every field is
61
+ * optional and absence is normal rather than an error.
62
+ */
63
+ export function parseAnthropicLimitHeaders(headers) {
64
+ const sessionUtilization = numberOf(headers, "anthropic-ratelimit-unified-5h-utilization");
65
+ const weeklyUtilization = numberOf(headers, "anthropic-ratelimit-unified-7d-utilization");
66
+ const info = {};
67
+ // Legacy per-tier counters — these ARE absolute remaining counts.
68
+ const requestsLimit = intOf(headers, "anthropic-ratelimit-requests-limit");
69
+ const requestsRemaining = intOf(headers, "anthropic-ratelimit-requests-remaining");
70
+ const requestsReset = headerOf(headers, "anthropic-ratelimit-requests-reset");
71
+ const tokensLimit = intOf(headers, "anthropic-ratelimit-tokens-limit");
72
+ const tokensRemaining = intOf(headers, "anthropic-ratelimit-tokens-remaining");
73
+ const tokensReset = headerOf(headers, "anthropic-ratelimit-tokens-reset");
74
+ const retryAfter = intOf(headers, "retry-after");
75
+ if (requestsLimit !== undefined) {
76
+ info.requestsLimit = requestsLimit;
77
+ }
78
+ if (requestsRemaining !== undefined) {
79
+ info.requestsRemaining = requestsRemaining;
80
+ }
81
+ if (requestsReset !== undefined) {
82
+ info.requestsReset = requestsReset;
83
+ }
84
+ if (tokensLimit !== undefined) {
85
+ info.tokensLimit = tokensLimit;
86
+ }
87
+ if (tokensRemaining !== undefined) {
88
+ info.tokensRemaining = tokensRemaining;
89
+ }
90
+ if (tokensReset !== undefined) {
91
+ info.tokensReset = tokensReset;
92
+ }
93
+ if (retryAfter !== undefined) {
94
+ info.retryAfter = retryAfter;
95
+ }
96
+ // Unified subscription windows — utilization only, no absolute remaining.
97
+ if (sessionUtilization !== undefined) {
98
+ info.sessionUtilization = sessionUtilization;
99
+ const left = leftPctFrom(sessionUtilization);
100
+ if (left !== undefined) {
101
+ info.sessionLeftPct = left;
102
+ }
103
+ }
104
+ const sessionStatus = headerOf(headers, "anthropic-ratelimit-unified-5h-status");
105
+ if (sessionStatus !== undefined) {
106
+ info.sessionStatus = sessionStatus;
107
+ }
108
+ const sessionResetAt = intOf(headers, "anthropic-ratelimit-unified-5h-reset");
109
+ if (sessionResetAt !== undefined) {
110
+ info.sessionResetAt = sessionResetAt;
111
+ }
112
+ if (weeklyUtilization !== undefined) {
113
+ info.weeklyUtilization = weeklyUtilization;
114
+ const left = leftPctFrom(weeklyUtilization);
115
+ if (left !== undefined) {
116
+ info.weeklyLeftPct = left;
117
+ }
118
+ }
119
+ const weeklyStatus = headerOf(headers, "anthropic-ratelimit-unified-7d-status");
120
+ if (weeklyStatus !== undefined) {
121
+ info.weeklyStatus = weeklyStatus;
122
+ }
123
+ const weeklyResetAt = intOf(headers, "anthropic-ratelimit-unified-7d-reset");
124
+ if (weeklyResetAt !== undefined) {
125
+ info.weeklyResetAt = weeklyResetAt;
126
+ }
127
+ const unifiedStatus = headerOf(headers, "anthropic-ratelimit-unified-status");
128
+ if (unifiedStatus !== undefined) {
129
+ info.unifiedStatus = unifiedStatus;
130
+ }
131
+ const overageStatus = headerOf(headers, "anthropic-ratelimit-unified-overage-status");
132
+ if (overageStatus !== undefined) {
133
+ info.overageStatus = overageStatus;
134
+ }
135
+ return info;
136
+ }
137
+ /** True when a parsed info object carries no usable signal at all. */
138
+ function isEmptyRateLimitInfo(info) {
139
+ return Object.keys(info).length === 0;
140
+ }
141
+ /**
142
+ * Build a snapshot from a response, or undefined when the response carries
143
+ * neither Anthropic rate-limit headers nor NeuroLink proxy metadata.
144
+ */
145
+ export function buildLimitSnapshot(headers, status, now = Date.now()) {
146
+ const rateLimit = parseAnthropicLimitHeaders(headers);
147
+ const quotaSource = headerOf(headers, "x-neurolink-quota-source");
148
+ const account = headerOf(headers, "x-neurolink-account");
149
+ const accountType = headerOf(headers, "x-neurolink-account-type");
150
+ const servedBy = headerOf(headers, "x-neurolink-served-by");
151
+ const poolAvailable = intOf(headers, "x-neurolink-pool-available");
152
+ const poolCooling = intOf(headers, "x-neurolink-pool-cooling");
153
+ const poolBest = intOf(headers, "x-neurolink-pool-best-session-left");
154
+ const coolingUntil = intOf(headers, "x-neurolink-account-cooling-until");
155
+ const coolingReason = headerOf(headers, "x-neurolink-account-cooling-reason");
156
+ const requestId = headerOf(headers, "x-request-id");
157
+ const hasProxyMetadata = quotaSource !== undefined ||
158
+ account !== undefined ||
159
+ servedBy !== undefined;
160
+ if (isEmptyRateLimitInfo(rateLimit) && !hasProxyMetadata) {
161
+ return undefined;
162
+ }
163
+ const pool = poolAvailable !== undefined ||
164
+ poolCooling !== undefined ||
165
+ poolBest !== undefined
166
+ ? {
167
+ ...(poolAvailable !== undefined ? { available: poolAvailable } : {}),
168
+ ...(poolCooling !== undefined ? { cooling: poolCooling } : {}),
169
+ ...(poolBest !== undefined ? { bestSessionLeftPct: poolBest } : {}),
170
+ }
171
+ : undefined;
172
+ return {
173
+ rateLimit,
174
+ ...(quotaSource === "live" ||
175
+ quotaSource === "snapshot" ||
176
+ quotaSource === "none"
177
+ ? { quotaSource }
178
+ : {}),
179
+ ...(account !== undefined ? { account } : {}),
180
+ ...(accountType !== undefined ? { accountType } : {}),
181
+ ...(servedBy !== undefined ? { servedBy } : {}),
182
+ ...(coolingUntil !== undefined
183
+ ? { accountCoolingUntil: coolingUntil }
184
+ : {}),
185
+ ...(coolingReason !== undefined
186
+ ? { accountCoolingReason: coolingReason }
187
+ : {}),
188
+ ...(pool ? { pool } : {}),
189
+ ...(requestId !== undefined ? { requestId } : {}),
190
+ status,
191
+ capturedAt: now,
192
+ };
193
+ }
194
+ /**
195
+ * Wrap a fetch so every response's limit headers are captured into the
196
+ * enclosing `withLimitCapture` scope. A no-op outside such a scope.
197
+ *
198
+ * Capture never alters the response and never throws — a parsing failure must
199
+ * not be able to break a request that the provider would otherwise complete.
200
+ */
201
+ export function wrapFetchWithLimitCapture(inner) {
202
+ return async (input, init) => {
203
+ const response = await inner(input, init);
204
+ const slot = limitCaptureStorage.getStore();
205
+ if (!slot) {
206
+ return response;
207
+ }
208
+ try {
209
+ const raw = {};
210
+ response.headers.forEach((value, key) => {
211
+ raw[key] = value;
212
+ });
213
+ slot.headers = raw;
214
+ const snapshot = buildLimitSnapshot(response.headers, response.status);
215
+ if (snapshot) {
216
+ // Last write wins: a retried or multi-step call reports the most
217
+ // recent upstream state, which is the one a caller acts on.
218
+ slot.snapshot = snapshot;
219
+ }
220
+ }
221
+ catch {
222
+ // Diagnostics only — never disturb the response.
223
+ }
224
+ return response;
225
+ };
226
+ }
227
+ /**
228
+ * Run `body` in a capture scope and return its result alongside whatever limit
229
+ * snapshot the underlying HTTP request(s) produced.
230
+ */
231
+ export async function withLimitCapture(body) {
232
+ const slot = {};
233
+ const result = await limitCaptureStorage.run(slot, body);
234
+ return {
235
+ result,
236
+ ...(slot.snapshot ? { snapshot: slot.snapshot } : {}),
237
+ };
238
+ }
239
+ /**
240
+ * Current scope's snapshot, if any. Lets a long-running loop (the streaming
241
+ * path) read limits mid-flight without unwinding the scope.
242
+ */
243
+ export function getCapturedLimitSnapshot() {
244
+ return limitCaptureStorage.getStore()?.snapshot;
245
+ }
246
+ /** Raw headers of the most recent captured response in this scope. */
247
+ export function getCapturedResponseHeaders() {
248
+ return limitCaptureStorage.getStore()?.headers;
249
+ }
250
+ /** Enter a capture scope without wrapping a single call — for streaming, where
251
+ * the scope must outlive the function that opened it. */
252
+ export function runInLimitCaptureScope(body) {
253
+ return limitCaptureStorage.run({}, body);
254
+ }
255
+ /**
256
+ * Attach limit state to the currently active OTel span.
257
+ *
258
+ * Uses the active span rather than threading one down from the generation
259
+ * layer: that layer is provider-agnostic and should not learn about Anthropic
260
+ * quota headers just to record them. The active span during a turn is the one
261
+ * already carrying `gen_ai.usage.*` and `neurolink.cost`, so "what did this
262
+ * cost" and "how much is left" answer from the same trace.
263
+ */
264
+ export function setLimitSpanAttributes(snapshot) {
265
+ const span = trace.getActiveSpan();
266
+ if (!span) {
267
+ return;
268
+ }
269
+ const { rateLimit } = snapshot;
270
+ const attrs = [
271
+ ["neurolink.claude.quota.session_left_pct", rateLimit.sessionLeftPct],
272
+ ["neurolink.claude.quota.weekly_left_pct", rateLimit.weeklyLeftPct],
273
+ ["neurolink.claude.quota.requests_remaining", rateLimit.requestsRemaining],
274
+ ["neurolink.claude.quota.tokens_remaining", rateLimit.tokensRemaining],
275
+ ["neurolink.claude.quota.source", snapshot.quotaSource],
276
+ ["neurolink.claude.account", snapshot.account],
277
+ ["neurolink.claude.served_by", snapshot.servedBy],
278
+ ["neurolink.claude.pool.available", snapshot.pool?.available],
279
+ [
280
+ "neurolink.claude.pool.best_session_left_pct",
281
+ snapshot.pool?.bestSessionLeftPct,
282
+ ],
283
+ ];
284
+ for (const [key, value] of attrs) {
285
+ if (value !== undefined) {
286
+ span.setAttribute(key, value);
287
+ }
288
+ }
289
+ }
290
+ /** Seconds-from-now for an epoch-seconds reset, or undefined if absent/past. */
291
+ function resetsInSeconds(resetAt, now) {
292
+ if (!resetAt || resetAt <= 0) {
293
+ return undefined;
294
+ }
295
+ // Tolerate a value already expressed in ms (year 2100 in seconds).
296
+ const ms = resetAt > 4_102_444_800 ? resetAt : resetAt * 1000;
297
+ return ms > now ? Math.round((ms - now) / 1000) : undefined;
298
+ }
299
+ /**
300
+ * Emit one structured line per request describing remaining capacity.
301
+ *
302
+ * Leads with headroom ("how much is left") because that is the figure an
303
+ * operator acts on; the raw utilization stays available on the snapshot for
304
+ * anything computing against it. Escalates to WARN when the session window is
305
+ * nearly spent or the provider has already flagged the account as
306
+ * throttled/rejected.
307
+ */
308
+ export function logClaudeLimitSnapshot(snapshot, model, now = Date.now()) {
309
+ const { rateLimit } = snapshot;
310
+ // A fallback provider served this — there is no Anthropic capacity to report.
311
+ if (snapshot.quotaSource === "none" && snapshot.servedBy) {
312
+ logger.debug("[Anthropic] request served without account quota", {
313
+ servedBy: snapshot.servedBy,
314
+ ...(model ? { model } : {}),
315
+ });
316
+ return;
317
+ }
318
+ const details = {
319
+ ...(model ? { model } : {}),
320
+ ...(snapshot.account ? { account: snapshot.account } : {}),
321
+ ...(snapshot.accountType ? { accountType: snapshot.accountType } : {}),
322
+ ...(snapshot.servedBy ? { servedBy: snapshot.servedBy } : {}),
323
+ ...(snapshot.quotaSource ? { quotaSource: snapshot.quotaSource } : {}),
324
+ };
325
+ if (rateLimit.sessionLeftPct !== undefined) {
326
+ details.sessionLeftPct = rateLimit.sessionLeftPct;
327
+ const resets = resetsInSeconds(rateLimit.sessionResetAt, now);
328
+ if (resets !== undefined) {
329
+ details.sessionResetsInSec = resets;
330
+ }
331
+ }
332
+ if (rateLimit.weeklyLeftPct !== undefined) {
333
+ details.weeklyLeftPct = rateLimit.weeklyLeftPct;
334
+ const resets = resetsInSeconds(rateLimit.weeklyResetAt, now);
335
+ if (resets !== undefined) {
336
+ details.weeklyResetsInSec = resets;
337
+ }
338
+ }
339
+ // API-key accounts report absolute remaining rather than a percentage.
340
+ if (rateLimit.requestsRemaining !== undefined) {
341
+ details.requestsRemaining = rateLimit.requestsRemaining;
342
+ }
343
+ if (rateLimit.tokensRemaining !== undefined) {
344
+ details.tokensRemaining = rateLimit.tokensRemaining;
345
+ }
346
+ if (rateLimit.retryAfter !== undefined) {
347
+ details.retryAfterSec = rateLimit.retryAfter;
348
+ }
349
+ if (snapshot.pool) {
350
+ details.pool = snapshot.pool;
351
+ }
352
+ if (Object.keys(details).length === 0) {
353
+ return;
354
+ }
355
+ const status = (rateLimit.unifiedStatus ??
356
+ rateLimit.sessionStatus ??
357
+ "").toLowerCase();
358
+ const lowHeadroom = rateLimit.sessionLeftPct !== undefined &&
359
+ rateLimit.sessionLeftPct <= LOW_HEADROOM_WARN_PCT;
360
+ const flagged = status === "throttled" || status === "rejected";
361
+ if (lowHeadroom || flagged) {
362
+ // `always`, not `warn`: this logger suppresses everything below `error`
363
+ // unless debug mode is on, and "you are about to run out of capacity" is
364
+ // precisely the thing an operator must see during a normal run. The
365
+ // routine per-request line below stays debug-gated so this stays rare
366
+ // enough to mean something.
367
+ logger.always(`[Anthropic] account limits running low — ${JSON.stringify({
368
+ ...details,
369
+ ...(status ? { status } : {}),
370
+ })}`);
371
+ return;
372
+ }
373
+ logger.info("[Anthropic] account limits", details);
374
+ }