@juspay/neurolink 10.4.1 → 10.4.3

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 (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +386 -389
  3. package/dist/cli/commands/proxy.d.ts +1 -1
  4. package/dist/cli/commands/proxy.js +124 -56
  5. package/dist/cli/commands/proxyAnalyze.js +10 -1
  6. package/dist/lib/providers/anthropic.js +18 -1
  7. package/dist/lib/providers/googleAiStudio.js +18 -1
  8. package/dist/lib/providers/googleNativeGemini3.d.ts +19 -1
  9. package/dist/lib/providers/googleNativeGemini3.js +61 -4
  10. package/dist/lib/providers/googleVertex.d.ts +48 -0
  11. package/dist/lib/providers/googleVertex.js +204 -108
  12. package/dist/lib/providers/openaiChatCompletionsBase.js +34 -2
  13. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +11 -5
  14. package/dist/lib/providers/openaiChatCompletionsClient.js +14 -8
  15. package/dist/lib/proxy/proxyAnalysis.js +136 -12
  16. package/dist/lib/proxy/requestLogger.d.ts +2 -0
  17. package/dist/lib/proxy/requestLogger.js +72 -13
  18. package/dist/lib/proxy/rollingProxyServer.js +79 -8
  19. package/dist/lib/proxy/rollingWorkerProcess.js +20 -15
  20. package/dist/lib/proxy/rollingWorkerProtocol.js +3 -0
  21. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
  22. package/dist/lib/proxy/rollingWorkerSupervisor.js +31 -11
  23. package/dist/lib/proxy/runtimeConfig.d.ts +1 -0
  24. package/dist/lib/proxy/runtimeConfig.js +20 -5
  25. package/dist/lib/proxy/socketWorkerRuntime.js +41 -13
  26. package/dist/lib/server/routes/claudeProxyRoutes.js +63 -75
  27. package/dist/lib/tools/toolDiscovery.d.ts +25 -3
  28. package/dist/lib/tools/toolDiscovery.js +76 -4
  29. package/dist/lib/types/proxy.d.ts +31 -1
  30. package/dist/lib/types/toolResolution.d.ts +10 -0
  31. package/dist/providers/anthropic.js +18 -1
  32. package/dist/providers/googleAiStudio.js +18 -1
  33. package/dist/providers/googleNativeGemini3.d.ts +19 -1
  34. package/dist/providers/googleNativeGemini3.js +61 -4
  35. package/dist/providers/googleVertex.d.ts +48 -0
  36. package/dist/providers/googleVertex.js +204 -108
  37. package/dist/providers/openaiChatCompletionsBase.js +34 -2
  38. package/dist/providers/openaiChatCompletionsClient.d.ts +11 -5
  39. package/dist/providers/openaiChatCompletionsClient.js +14 -8
  40. package/dist/proxy/proxyAnalysis.js +136 -12
  41. package/dist/proxy/requestLogger.d.ts +2 -0
  42. package/dist/proxy/requestLogger.js +72 -13
  43. package/dist/proxy/rollingProxyServer.js +79 -8
  44. package/dist/proxy/rollingWorkerProcess.js +20 -15
  45. package/dist/proxy/rollingWorkerProtocol.js +3 -0
  46. package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
  47. package/dist/proxy/rollingWorkerSupervisor.js +31 -11
  48. package/dist/proxy/runtimeConfig.d.ts +1 -0
  49. package/dist/proxy/runtimeConfig.js +20 -5
  50. package/dist/proxy/socketWorkerRuntime.js +41 -13
  51. package/dist/server/routes/claudeProxyRoutes.js +63 -75
  52. package/dist/tools/toolDiscovery.d.ts +25 -3
  53. package/dist/tools/toolDiscovery.js +76 -4
  54. package/dist/types/proxy.d.ts +31 -1
  55. package/dist/types/toolResolution.d.ts +10 -0
  56. package/package.json +1 -1
@@ -79,7 +79,6 @@ export class RollingWorkerSupervisor {
79
79
  ? this.replacement
80
80
  : Promise.reject(ErrorFactory.proxyWorkerLifecycle(`worker replacement for v${this.candidate?.expectedVersion ?? "unknown"} is already in progress`, { requestedVersion: expectedVersion }));
81
81
  }
82
- this.lastFailure = null;
83
82
  this.replacement = this.spawnCandidate(expectedVersion).finally(() => {
84
83
  this.replacement = null;
85
84
  });
@@ -242,6 +241,16 @@ export class RollingWorkerSupervisor {
242
241
  }
243
242
  return;
244
243
  }
244
+ if (message.type === "proxy-worker:replacement-requested") {
245
+ if (this.active?.generation === generation) {
246
+ this.options.onReplacementRequested?.({
247
+ generation,
248
+ pid: handle.pid,
249
+ reason: message.reason,
250
+ });
251
+ }
252
+ return;
253
+ }
245
254
  if (message.type === "proxy-worker:ready") {
246
255
  if (message.version !== expectedVersion) {
247
256
  finish(new Error(`worker ${handle.pid} reported v${message.version}; expected v${expectedVersion}`));
@@ -309,7 +318,7 @@ export class RollingWorkerSupervisor {
309
318
  if (!this.closed) {
310
319
  this.recordFailure(generation, expectedVersion, "runtime", `worker exited (code=${code ?? "none"}, signal=${signal ?? "none"})`);
311
320
  }
312
- this.options.log?.(`[proxy-supervisor] active worker exited generation=${generation} pid=${handle.pid}`);
321
+ this.options.log?.(`[proxy-supervisor] active worker exited generation=${generation} pid=${handle.pid} code=${code ?? "none"} signal=${signal ?? "none"}`);
313
322
  }
314
323
  const drained = this.draining.get(generation);
315
324
  if (drained) {
@@ -356,23 +365,27 @@ export class RollingWorkerSupervisor {
356
365
  try {
357
366
  worker.handle.sendSocket(worker.generation, socket, (error) => {
358
367
  if (error) {
359
- this.handleTransferFailure(worker, socket);
368
+ this.handleTransferFailure(worker, socket, error);
360
369
  }
361
370
  });
362
371
  }
363
- catch {
364
- this.handleTransferFailure(worker, socket);
372
+ catch (error) {
373
+ this.handleTransferFailure(worker, socket, error);
365
374
  }
366
375
  }
367
- handleTransferFailure(worker, socket) {
376
+ handleTransferFailure(worker, socket, error) {
368
377
  this.failedTransfers += 1;
369
- this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket`);
378
+ const detail = this.describeTransferError(error);
379
+ this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket: ${detail}`);
380
+ this.options.log?.(`[proxy-supervisor] socket transfer failed generation=${worker.generation} pid=${worker.handle.pid}: ${detail}`);
370
381
  if (this.active?.generation === worker.generation && !this.closed) {
371
- // Do not let worker-side graceful shutdown call shutdown(2) on an
372
- // offered-but-uncommitted duplicate descriptor.
373
- worker.dispose();
374
- worker.handle.terminate("SIGKILL");
375
382
  this.active = null;
383
+ this.draining.set(worker.generation, worker);
384
+ // The child may own a duplicate of an incompletely transferred socket.
385
+ // SIGKILL closes its descriptor without worker-side shutdown(2), after
386
+ // which the parent rejects its copy rather than attempting unsafe replay.
387
+ worker.handle.terminate("SIGKILL");
388
+ this.publishState();
376
389
  }
377
390
  this.rejectSocket(socket);
378
391
  }
@@ -381,6 +394,13 @@ export class RollingWorkerSupervisor {
381
394
  socket.destroy();
382
395
  this.publishState();
383
396
  }
397
+ describeTransferError(error) {
398
+ if (!(error instanceof Error)) {
399
+ return String(error ?? "unknown transfer failure");
400
+ }
401
+ const code = error.code;
402
+ return code ? `${code}: ${error.message}` : error.message;
403
+ }
384
404
  recordFailure(generation, version, phase, message) {
385
405
  this.lastFailure = {
386
406
  at: new Date().toISOString(),
@@ -10,6 +10,7 @@ export declare class ProxyRuntimeConfigStore {
10
10
  private reloadTimer;
11
11
  private configFileObserved;
12
12
  private envFileObserved;
13
+ private currentEnvFileHash;
13
14
  private readonly watchListener;
14
15
  private constructor();
15
16
  static create(options: ProxyRuntimeConfigStoreOptions): Promise<ProxyRuntimeConfigStore>;
@@ -162,11 +162,13 @@ function assertResolvedRoutingValues(value, path = "routing") {
162
162
  async function buildCandidate(options, generation, allowMissingConfig, allowMissingEnvFile, rejectInvalidHotEnv) {
163
163
  const effectiveEnv = { ...options.baseEnv };
164
164
  let envFilePresent = false;
165
+ let envFileValues = {};
165
166
  if (options.envFilePath) {
166
167
  try {
167
- const envContent = await readFile(options.envFilePath, "utf8");
168
+ const envFileContent = await readFile(options.envFilePath, "utf8");
168
169
  const { parse } = await import("dotenv");
169
- Object.assign(effectiveEnv, parse(envContent));
170
+ envFileValues = parse(envFileContent);
171
+ Object.assign(effectiveEnv, envFileValues);
170
172
  envFilePresent = true;
171
173
  }
172
174
  catch (error) {
@@ -218,6 +220,12 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
218
220
  accountAllowlist: routing.accountAllowlist,
219
221
  })
220
222
  : undefined;
223
+ const envFileHash = createHash("sha256")
224
+ .update(envFilePresent
225
+ ? JSON.stringify(Object.entries(envFileValues).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
226
+ : "[missing]")
227
+ .digest("hex")
228
+ .slice(0, 16);
221
229
  const fingerprintSource = JSON.stringify({
222
230
  strategy,
223
231
  passthrough: options.passthrough,
@@ -249,6 +257,7 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
249
257
  }),
250
258
  configFilePresent,
251
259
  envFilePresent,
260
+ envFileHash,
252
261
  };
253
262
  }
254
263
  /** Atomic last-known-good runtime configuration with file-triggered reloads. */
@@ -262,6 +271,7 @@ export class ProxyRuntimeConfigStore {
262
271
  reloadTimer;
263
272
  configFileObserved;
264
273
  envFileObserved;
274
+ currentEnvFileHash;
265
275
  watchListener = (current, previous) => {
266
276
  if (current.mtimeMs === previous.mtimeMs &&
267
277
  current.size === previous.size &&
@@ -270,11 +280,12 @@ export class ProxyRuntimeConfigStore {
270
280
  }
271
281
  this.scheduleWatchReload();
272
282
  };
273
- constructor(options, snapshot, configFileObserved, envFileObserved) {
283
+ constructor(options, snapshot, configFileObserved, envFileObserved, envFileHash) {
274
284
  this.options = { ...options, baseEnv: { ...options.baseEnv } };
275
285
  this.currentSnapshot = snapshot;
276
286
  this.configFileObserved = configFileObserved;
277
287
  this.envFileObserved = envFileObserved;
288
+ this.currentEnvFileHash = envFileHash;
278
289
  this.status = {
279
290
  configPath: options.configPath,
280
291
  ...(options.envFilePath ? { envFilePath: options.envFilePath } : {}),
@@ -287,7 +298,7 @@ export class ProxyRuntimeConfigStore {
287
298
  }
288
299
  static async create(options) {
289
300
  const candidate = await buildCandidate(options, 1, true, true, false);
290
- return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent);
301
+ return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent, candidate.envFileHash);
291
302
  }
292
303
  getSnapshot() {
293
304
  return this.currentSnapshot;
@@ -354,7 +365,8 @@ export class ProxyRuntimeConfigStore {
354
365
  const candidate = await buildCandidate(this.options, this.currentSnapshot.generation + 1, !this.configFileObserved, !this.envFileObserved, true);
355
366
  this.configFileObserved ||= candidate.configFilePresent;
356
367
  this.envFileObserved ||= candidate.envFilePresent;
357
- if (candidate.snapshot.configHash === this.currentSnapshot.configHash) {
368
+ if (candidate.snapshot.configHash === this.currentSnapshot.configHash &&
369
+ candidate.envFileHash === this.currentEnvFileHash) {
358
370
  this.status = {
359
371
  ...this.status,
360
372
  lastReloadAt: attemptedAt,
@@ -369,7 +381,9 @@ export class ProxyRuntimeConfigStore {
369
381
  this.notifyReloadListeners(result);
370
382
  return result;
371
383
  }
384
+ const environmentChanged = candidate.envFileHash !== this.currentEnvFileHash;
372
385
  this.currentSnapshot = candidate.snapshot;
386
+ this.currentEnvFileHash = candidate.envFileHash;
373
387
  this.status = {
374
388
  ...this.status,
375
389
  generation: candidate.snapshot.generation,
@@ -392,6 +406,7 @@ export class ProxyRuntimeConfigStore {
392
406
  applied: true,
393
407
  changed: true,
394
408
  generation: candidate.snapshot.generation,
409
+ ...(environmentChanged ? { environmentChanged: true } : {}),
395
410
  };
396
411
  this.notifyReloadListeners(result);
397
412
  return result;
@@ -8,6 +8,7 @@ function isTransferableProxySocket(handle) {
8
8
  typeof candidate.resume === "function" &&
9
9
  typeof candidate.destroy === "function" &&
10
10
  typeof candidate.end === "function" &&
11
+ typeof candidate.off === "function" &&
11
12
  typeof candidate.once === "function");
12
13
  }
13
14
  function destroyTransferredHandle(handle) {
@@ -153,6 +154,16 @@ export function attachSocketWorkerProcess(server, input) {
153
154
  // synchronously here would truncate that in-flight request.
154
155
  setImmediate(() => runtime.drain());
155
156
  };
157
+ const takePendingSocket = (socketId) => {
158
+ const pending = pendingSockets.get(socketId);
159
+ if (!pending) {
160
+ return undefined;
161
+ }
162
+ pendingSockets.delete(socketId);
163
+ pending.socket.off("error", pending.onError);
164
+ pending.socket.off("close", pending.onClose);
165
+ return pending.socket;
166
+ };
156
167
  const onMessage = (message, handle) => {
157
168
  if (message &&
158
169
  typeof message === "object" &&
@@ -176,7 +187,27 @@ export function attachSocketWorkerProcess(server, input) {
176
187
  socket.destroy();
177
188
  return;
178
189
  }
179
- pendingSockets.set(socketId, socket);
190
+ // A client can reset while the transferred handle is paused between
191
+ // acceptance and commit. The HTTP server has not seen the socket yet,
192
+ // so it cannot install its normal transport-error handler for us.
193
+ const onError = () => {
194
+ if (pendingSockets.get(socketId)?.socket !== socket) {
195
+ return;
196
+ }
197
+ takePendingSocket(socketId);
198
+ socket.destroy();
199
+ drainWhenPendingSettled();
200
+ };
201
+ const onClose = () => {
202
+ if (pendingSockets.get(socketId)?.socket !== socket) {
203
+ return;
204
+ }
205
+ takePendingSocket(socketId);
206
+ drainWhenPendingSettled();
207
+ };
208
+ pendingSockets.set(socketId, { socket, onError, onClose });
209
+ socket.once("error", onError);
210
+ socket.once("close", onClose);
180
211
  try {
181
212
  process.send({
182
213
  type: "proxy-worker:socket-accepted",
@@ -185,14 +216,14 @@ export function attachSocketWorkerProcess(server, input) {
185
216
  socketId,
186
217
  }, (error) => {
187
218
  if (error) {
188
- pendingSockets.delete(socketId);
189
- socket.destroy();
219
+ takePendingSocket(socketId)?.destroy();
220
+ drainWhenPendingSettled();
190
221
  }
191
222
  });
192
223
  }
193
224
  catch {
194
- pendingSockets.delete(socketId);
195
- socket.destroy();
225
+ takePendingSocket(socketId)?.destroy();
226
+ drainWhenPendingSettled();
196
227
  }
197
228
  }
198
229
  else {
@@ -229,11 +260,10 @@ export function attachSocketWorkerProcess(server, input) {
229
260
  }
230
261
  else if (message.type === "proxy-worker:socket-commit" ||
231
262
  message.type === "proxy-worker:socket-cancel") {
232
- const socket = pendingSockets.get(message.socketId);
263
+ const socket = takePendingSocket(message.socketId);
233
264
  if (!socket) {
234
265
  return;
235
266
  }
236
- pendingSockets.delete(message.socketId);
237
267
  if (message.type === "proxy-worker:socket-commit") {
238
268
  runtime.acceptSocket(socket);
239
269
  }
@@ -259,10 +289,9 @@ export function attachSocketWorkerProcess(server, input) {
259
289
  // is exiting. The zero-downtime guarantee applies to the rolling handoff
260
290
  // (control-message) path, not to process termination.
261
291
  const drain = () => {
262
- for (const socket of pendingSockets.values()) {
263
- socket.destroy();
292
+ for (const socketId of [...pendingSockets.keys()]) {
293
+ takePendingSocket(socketId)?.destroy();
264
294
  }
265
- pendingSockets.clear();
266
295
  runtime.drain();
267
296
  };
268
297
  const onTerminationSignal = () => drain();
@@ -283,10 +312,9 @@ export function attachSocketWorkerProcess(server, input) {
283
312
  process.off("disconnect", drain);
284
313
  process.off("SIGTERM", onTerminationSignal);
285
314
  process.off("SIGINT", onTerminationSignal);
286
- for (const socket of pendingSockets.values()) {
287
- socket.destroy();
315
+ for (const socketId of [...pendingSockets.keys()]) {
316
+ takePendingSocket(socketId)?.destroy();
288
317
  }
289
- pendingSockets.clear();
290
318
  runtime.close();
291
319
  },
292
320
  };
@@ -2241,6 +2241,7 @@ async function handleAnthropicSuccessfulResponse(args) {
2241
2241
  attemptNumber,
2242
2242
  finalBodyStr,
2243
2243
  upstreamSpan,
2244
+ logAttempt,
2244
2245
  logProxyBody,
2245
2246
  logFinalRequest,
2246
2247
  });
@@ -2248,6 +2249,8 @@ async function handleAnthropicSuccessfulResponse(args) {
2248
2249
  async function handleAnthropicStreamingSuccessResponse(args) {
2249
2250
  const { account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
2250
2251
  if (!response.body) {
2252
+ recordAttemptError(account.label, account.type, 502);
2253
+ logAttempt(502, "stream_error", "No response body from upstream");
2251
2254
  upstreamSpan?.end();
2252
2255
  tracer?.setError("stream_error", "No response body from upstream");
2253
2256
  tracer?.end(502, Date.now() - requestStartTime);
@@ -2386,6 +2389,9 @@ async function handleAnthropicStreamingSuccessResponse(args) {
2386
2389
  failure: { message: preflight.message, rateLimit: isRateLimit },
2387
2390
  };
2388
2391
  }
2392
+ logAttempt(response.status, undefined, undefined, {
2393
+ attemptDurationMs: Date.now() - fetchStartMs,
2394
+ });
2389
2395
  const streamOutcomeTracker = createStreamTerminalOutcomeTracker();
2390
2396
  let mainStreamClosed = false;
2391
2397
  const remainingStream = new ReadableStream({
@@ -2693,8 +2699,11 @@ function attachAnthropicSuccessStreamTelemetry(args) {
2693
2699
  });
2694
2700
  }
2695
2701
  async function handleAnthropicJsonSuccessResponse(args) {
2696
- const { account, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2702
+ const { account, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
2697
2703
  const responseText = await response.text();
2704
+ logAttempt(response.status, undefined, undefined, {
2705
+ attemptDurationMs: Date.now() - fetchStartMs,
2706
+ });
2698
2707
  tracer?.logUpstreamResponseBody(responseText);
2699
2708
  logProxyBody({
2700
2709
  phase: "upstream_response",
@@ -2779,8 +2788,8 @@ async function handleAnthropicJsonSuccessResponse(args) {
2779
2788
  }
2780
2789
  return { response: responseJson };
2781
2790
  }
2782
- async function handleAnthropicSuccessfulRetryResponse(args) {
2783
- const { body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2791
+ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
2792
+ const { account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
2784
2793
  const retryQuota = parseQuotaHeaders(retryResp.headers);
2785
2794
  if (retryQuota) {
2786
2795
  // Keep the auth-retry success path in parity with the main success path:
@@ -2802,63 +2811,11 @@ async function handleAnthropicSuccessfulRetryResponse(args) {
2802
2811
  });
2803
2812
  });
2804
2813
  }
2805
- if (body.stream && retryResp.body) {
2806
- const retryReader = retryResp.body.getReader();
2807
- const streamOutcomeTracker = createStreamTerminalOutcomeTracker();
2808
- let retryStreamClosed = false;
2809
- const retryStream = new ReadableStream({
2810
- async pull(controller) {
2811
- if (retryStreamClosed) {
2812
- return;
2813
- }
2814
- try {
2815
- const { done, value } = await retryReader.read();
2816
- if (retryStreamClosed) {
2817
- return;
2818
- }
2819
- if (done) {
2820
- retryStreamClosed = true;
2821
- streamOutcomeTracker.complete();
2822
- controller.close();
2823
- return;
2824
- }
2825
- controller.enqueue(value);
2826
- }
2827
- catch (streamErr) {
2828
- const errMsg = describeTransportError(streamErr);
2829
- logger.always(`[proxy] mid-stream error (auth-retry) account=${account.label}: ${errMsg}`);
2830
- streamOutcomeTracker.fail(errMsg);
2831
- if (!retryStreamClosed) {
2832
- retryStreamClosed = true;
2833
- const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Upstream stream interrupted: ${errMsg}` } })}\n\n`;
2834
- controller.enqueue(new TextEncoder().encode(errorEvent));
2835
- controller.close();
2836
- }
2837
- }
2838
- },
2839
- cancel() {
2840
- retryStreamClosed = true;
2841
- streamOutcomeTracker.cancel();
2842
- return retryReader.cancel();
2843
- },
2844
- });
2845
- return attachAnthropicSuccessStreamTelemetry({
2846
- account,
2847
- response: retryResp,
2848
- responseHeaders: Object.fromEntries([...retryResp.headers.entries()]),
2849
- remainingStream: retryStream,
2850
- streamOutcome: streamOutcomeTracker.outcome,
2851
- tracer,
2852
- requestStartTime,
2853
- attemptNumber,
2854
- finalBodyStr,
2855
- upstreamSpan,
2856
- logProxyBody,
2857
- logFinalRequest,
2858
- });
2859
- }
2860
2814
  const retryRespHeaders = Object.fromEntries([...retryResp.headers.entries()]);
2861
2815
  const retryText = await retryResp.text();
2816
+ logAttempt(retryResp.status, undefined, undefined, {
2817
+ attemptDurationMs: Date.now() - fetchStartMs,
2818
+ });
2862
2819
  tracer?.logUpstreamResponseHeaders(retryRespHeaders);
2863
2820
  tracer?.logUpstreamResponseBody(retryText);
2864
2821
  logProxyBody({
@@ -2960,7 +2917,7 @@ async function handleAnthropicAuthRetry(args) {
2960
2917
  const retryLogAttempt = (status, errorType, errorMessage, extra) => logAttempt(status, errorType, errorMessage, {
2961
2918
  ...extra,
2962
2919
  attempt: retryAttemptNumber,
2963
- attemptDurationMs: Date.now() - retryAttemptStartedAt,
2920
+ attemptDurationMs: extra?.attemptDurationMs ?? Date.now() - retryAttemptStartedAt,
2964
2921
  });
2965
2922
  const retryBodyStr = buildUpstreamBody(account.token).bodyStr;
2966
2923
  const retryFetchStartMs = Date.now();
@@ -2986,23 +2943,54 @@ async function handleAnthropicAuthRetry(args) {
2986
2943
  authRetrySucceeded = true;
2987
2944
  accountState.consecutiveRefreshFailures = 0;
2988
2945
  logger.always(`[proxy] ← 200 account=${account.label} (after ${authRetry + 1} refresh(es))`);
2989
- const successResponse = await handleAnthropicSuccessfulRetryResponse({
2990
- ctx,
2991
- body,
2992
- account,
2993
- accountState,
2994
- retryResp,
2995
- tracer,
2996
- requestStartTime,
2997
- fetchStartMs: retryFetchStartMs,
2998
- attemptNumber: retryAttemptNumber,
2999
- finalBodyStr: retryBodyStr,
3000
- upstreamSpan: currentUpstreamSpan,
3001
- logProxyBody,
3002
- logFinalRequest,
3003
- });
2946
+ const successResult = body.stream
2947
+ ? await handleAnthropicSuccessfulResponse({
2948
+ ctx,
2949
+ body,
2950
+ account,
2951
+ accountState,
2952
+ response: retryResp,
2953
+ tracer,
2954
+ requestStartTime,
2955
+ fetchStartMs: retryFetchStartMs,
2956
+ attemptNumber: retryAttemptNumber,
2957
+ finalBodyStr: retryBodyStr,
2958
+ upstreamSpan: currentUpstreamSpan,
2959
+ logAttempt: retryLogAttempt,
2960
+ logProxyBody,
2961
+ logFinalRequest,
2962
+ })
2963
+ : {
2964
+ response: await handleAnthropicSuccessfulNonStreamRetryResponse({
2965
+ account,
2966
+ accountState,
2967
+ retryResp,
2968
+ tracer,
2969
+ requestStartTime,
2970
+ fetchStartMs: retryFetchStartMs,
2971
+ attemptNumber: retryAttemptNumber,
2972
+ finalBodyStr: retryBodyStr,
2973
+ upstreamSpan: currentUpstreamSpan,
2974
+ logAttempt: retryLogAttempt,
2975
+ logProxyBody,
2976
+ logFinalRequest,
2977
+ }),
2978
+ };
2979
+ if ("retryNextAccount" in successResult) {
2980
+ const failure = successResult.failure;
2981
+ return {
2982
+ continueLoop: true,
2983
+ lastError: failure?.message ?? currentLastError,
2984
+ authFailureMessage: currentAuthFailureMessage,
2985
+ sawRateLimit: currentSawRateLimit || Boolean(failure?.rateLimit),
2986
+ sawTransientFailure: currentSawTransientFailure ||
2987
+ Boolean(failure && !failure.rateLimit),
2988
+ sawNetworkError: currentSawNetworkError,
2989
+ upstreamSpan: undefined,
2990
+ };
2991
+ }
3004
2992
  return {
3005
- response: successResponse,
2993
+ response: successResult.response,
3006
2994
  continueLoop: false,
3007
2995
  lastError: currentLastError,
3008
2996
  authFailureMessage: currentAuthFailureMessage,
@@ -6,12 +6,19 @@
6
6
  * requested tools, and previously discovered tools) plus ONE `search_tools`
7
7
  * meta-tool whose description embeds a compact name+summary catalog of the
8
8
  * deferred tools. Calling `search_tools` loads matching tools:
9
- * - immediately into the live tool record (providers that re-read the tools
10
- * record between agent-loop steps the AI SDK path — can call them on the
11
- * very next step), and
9
+ * - immediately into the live tool record the AI SDK path re-reads the
10
+ * record between agent-loop steps, and the native loops (Vertex
11
+ * Gemini/Claude, AI Studio, chat-completions, direct Anthropic) re-resolve
12
+ * against it on a dispatch miss and refresh their wire declarations per
13
+ * step — so discovered tools are callable on the very next step, and
12
14
  * - into the session pin set (all providers include them in full on every
13
15
  * subsequent call of the session).
14
16
  *
17
+ * The record additionally carries a symbol-keyed {@link DeferredToolResolver}
18
+ * (see `resolveDeferredTool`) so a loop can hydrate a cataloged tool the
19
+ * model called directly by name WITHOUT a prior search_tools call — the
20
+ * catalog advertises exact names, so models legitimately do this.
21
+ *
15
22
  * Evidence for this design: catalogs past ~30-50 tools measurably degrade
16
23
  * tool-selection accuracy, and deferral+search both cuts definition tokens by
17
24
  * ~85% and RAISES selection accuracy (Anthropic MCP-eval 49%→74% on Opus 4).
@@ -46,3 +53,18 @@ export declare function partitionToolsForDiscovery(tools: Record<string, Tool>,
46
53
  /** Called with newly discovered names — persists session pins. */
47
54
  onHydrate: (names: string[]) => void;
48
55
  }): Record<string, Tool>;
56
+ /**
57
+ * Hydrate-and-return a deferred tool by name, when `record` was produced by
58
+ * `partitionToolsForDiscovery` and `name` is in its deferred catalog.
59
+ * Returns `undefined` for records without a resolver (discovery off) and for
60
+ * names that are genuinely unknown — callers keep their hallucinated-name
61
+ * handling for that case.
62
+ */
63
+ export declare function resolveDeferredTool(record: Record<string, Tool> | undefined, name: string): Tool | undefined;
64
+ /**
65
+ * Live tool lookup for native agent loops on a dispatch miss: first re-reads
66
+ * the record (a tool hydrated by search_tools after the loop built its
67
+ * pre-step snapshot), then falls back to auto-hydrating from the deferred
68
+ * catalog. Returns `undefined` only when the name is genuinely unknown.
69
+ */
70
+ export declare function resolveLiveTool(record: Record<string, Tool> | undefined, name: string): Tool | undefined;
@@ -6,12 +6,19 @@
6
6
  * requested tools, and previously discovered tools) plus ONE `search_tools`
7
7
  * meta-tool whose description embeds a compact name+summary catalog of the
8
8
  * deferred tools. Calling `search_tools` loads matching tools:
9
- * - immediately into the live tool record (providers that re-read the tools
10
- * record between agent-loop steps the AI SDK path — can call them on the
11
- * very next step), and
9
+ * - immediately into the live tool record the AI SDK path re-reads the
10
+ * record between agent-loop steps, and the native loops (Vertex
11
+ * Gemini/Claude, AI Studio, chat-completions, direct Anthropic) re-resolve
12
+ * against it on a dispatch miss and refresh their wire declarations per
13
+ * step — so discovered tools are callable on the very next step, and
12
14
  * - into the session pin set (all providers include them in full on every
13
15
  * subsequent call of the session).
14
16
  *
17
+ * The record additionally carries a symbol-keyed {@link DeferredToolResolver}
18
+ * (see `resolveDeferredTool`) so a loop can hydrate a cataloged tool the
19
+ * model called directly by name WITHOUT a prior search_tools call — the
20
+ * catalog advertises exact names, so models legitimately do this.
21
+ *
15
22
  * Evidence for this design: catalogs past ~30-50 tools measurably degrade
16
23
  * tool-selection accuracy, and deferral+search both cuts definition tokens by
17
24
  * ~85% and RAISES selection accuracy (Anthropic MCP-eval 49%→74% on Opus 4).
@@ -23,6 +30,14 @@ import { z } from "zod";
23
30
  import { tool as createAISDKTool } from "../utils/tool.js";
24
31
  import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
25
32
  import { logger } from "../utils/logger.js";
33
+ /**
34
+ * Symbol key under which the hot record carries its deferred-tool resolver.
35
+ * Symbol-keyed properties are skipped by Object.keys/entries, for-in, and
36
+ * JSON.stringify, so no declaration builder (or the AI SDK) ever sees the
37
+ * resolver as a tool. `Symbol.for` (global registry) keeps the key stable
38
+ * even if this module is loaded twice (ESM/CJS dual load of the bundle).
39
+ */
40
+ const DEFERRED_TOOL_RESOLVER_KEY = Symbol.for("neurolink.deferredToolResolver");
26
41
  /**
27
42
  * Above this many tools, a WARN suggests enabling `tools.discovery` when it
28
43
  * is off. Chosen from the measured degradation range (30-50 tools).
@@ -162,6 +177,35 @@ export function partitionToolsForDiscovery(tools, args) {
162
177
  };
163
178
  });
164
179
  hot["search_tools"] = buildSearchTool(entries, tools, hot, args.onHydrate);
180
+ // Dispatch-miss escape hatch for the native agent loops: hydrate a
181
+ // deferred tool the model called directly by its cataloged name (the
182
+ // catalog advertises exact names, so this is legitimate model behavior,
183
+ // not hallucination). Hydration mutates the hot record and persists the
184
+ // session pin — identical side effects to a search_tools hit.
185
+ const resolver = (name) => {
186
+ if (name in hot) {
187
+ return hot[name];
188
+ }
189
+ if (!deferredSet.has(name)) {
190
+ return undefined;
191
+ }
192
+ const toolDef = tools[name];
193
+ if (!toolDef) {
194
+ return undefined;
195
+ }
196
+ hot[name] = toolDef;
197
+ args.onHydrate([name]);
198
+ logger.debug("[ToolDiscovery] Auto-hydrated deferred tool on direct call", {
199
+ name,
200
+ });
201
+ return toolDef;
202
+ };
203
+ Object.defineProperty(hot, DEFERRED_TOOL_RESOLVER_KEY, {
204
+ value: resolver,
205
+ enumerable: false,
206
+ writable: false,
207
+ configurable: true,
208
+ });
165
209
  logger.debug("[ToolDiscovery] Deferred external tools behind search_tools", {
166
210
  hotCount: Object.keys(hot).length - 1,
167
211
  deferredCount: deferredNames.length,
@@ -169,6 +213,34 @@ export function partitionToolsForDiscovery(tools, args) {
169
213
  });
170
214
  return hot;
171
215
  }
216
+ /**
217
+ * Hydrate-and-return a deferred tool by name, when `record` was produced by
218
+ * `partitionToolsForDiscovery` and `name` is in its deferred catalog.
219
+ * Returns `undefined` for records without a resolver (discovery off) and for
220
+ * names that are genuinely unknown — callers keep their hallucinated-name
221
+ * handling for that case.
222
+ */
223
+ export function resolveDeferredTool(record, name) {
224
+ if (!record) {
225
+ return undefined;
226
+ }
227
+ const resolver = record[DEFERRED_TOOL_RESOLVER_KEY];
228
+ return typeof resolver === "function"
229
+ ? resolver(name)
230
+ : undefined;
231
+ }
232
+ /**
233
+ * Live tool lookup for native agent loops on a dispatch miss: first re-reads
234
+ * the record (a tool hydrated by search_tools after the loop built its
235
+ * pre-step snapshot), then falls back to auto-hydrating from the deferred
236
+ * catalog. Returns `undefined` only when the name is genuinely unknown.
237
+ */
238
+ export function resolveLiveTool(record, name) {
239
+ if (!record) {
240
+ return undefined;
241
+ }
242
+ return record[name] ?? resolveDeferredTool(record, name);
243
+ }
172
244
  function buildSearchTool(entries, allTools, hotRecord, onHydrate) {
173
245
  const catalog = renderCatalog(entries);
174
246
  const searchTool = createAISDKTool({
@@ -221,7 +293,7 @@ function buildSearchTool(entries, allTools, hotRecord, onHydrate) {
221
293
  return {
222
294
  found: loaded.length,
223
295
  tools: loaded,
224
- message: "These tools are now loaded for this session. Call them directly by name with arguments matching their inputSchema. If a call reports the tool as unavailable, it will be available from the next message onward.",
296
+ message: "These tools are now loaded for this session. Call them directly by name with arguments matching their inputSchema.",
225
297
  };
226
298
  },
227
299
  });