@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +386 -389
- package/dist/cli/commands/proxy.d.ts +1 -1
- package/dist/cli/commands/proxy.js +124 -56
- package/dist/cli/commands/proxyAnalyze.js +10 -1
- package/dist/lib/providers/anthropic.js +18 -1
- package/dist/lib/providers/googleAiStudio.js +18 -1
- package/dist/lib/providers/googleNativeGemini3.d.ts +19 -1
- package/dist/lib/providers/googleNativeGemini3.js +61 -4
- package/dist/lib/providers/googleVertex.d.ts +48 -0
- package/dist/lib/providers/googleVertex.js +204 -108
- package/dist/lib/providers/openaiChatCompletionsBase.js +34 -2
- package/dist/lib/providers/openaiChatCompletionsClient.d.ts +11 -5
- package/dist/lib/providers/openaiChatCompletionsClient.js +14 -8
- package/dist/lib/proxy/proxyAnalysis.js +136 -12
- package/dist/lib/proxy/requestLogger.d.ts +2 -0
- package/dist/lib/proxy/requestLogger.js +72 -13
- package/dist/lib/proxy/rollingProxyServer.js +79 -8
- package/dist/lib/proxy/rollingWorkerProcess.js +20 -15
- package/dist/lib/proxy/rollingWorkerProtocol.js +3 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.js +31 -11
- package/dist/lib/proxy/runtimeConfig.d.ts +1 -0
- package/dist/lib/proxy/runtimeConfig.js +20 -5
- package/dist/lib/proxy/socketWorkerRuntime.js +41 -13
- package/dist/lib/server/routes/claudeProxyRoutes.js +63 -75
- package/dist/lib/tools/toolDiscovery.d.ts +25 -3
- package/dist/lib/tools/toolDiscovery.js +76 -4
- package/dist/lib/types/proxy.d.ts +31 -1
- package/dist/lib/types/toolResolution.d.ts +10 -0
- package/dist/providers/anthropic.js +18 -1
- package/dist/providers/googleAiStudio.js +18 -1
- package/dist/providers/googleNativeGemini3.d.ts +19 -1
- package/dist/providers/googleNativeGemini3.js +61 -4
- package/dist/providers/googleVertex.d.ts +48 -0
- package/dist/providers/googleVertex.js +204 -108
- package/dist/providers/openaiChatCompletionsBase.js +34 -2
- package/dist/providers/openaiChatCompletionsClient.d.ts +11 -5
- package/dist/providers/openaiChatCompletionsClient.js +14 -8
- package/dist/proxy/proxyAnalysis.js +136 -12
- package/dist/proxy/requestLogger.d.ts +2 -0
- package/dist/proxy/requestLogger.js +72 -13
- package/dist/proxy/rollingProxyServer.js +79 -8
- package/dist/proxy/rollingWorkerProcess.js +20 -15
- package/dist/proxy/rollingWorkerProtocol.js +3 -0
- package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
- package/dist/proxy/rollingWorkerSupervisor.js +31 -11
- package/dist/proxy/runtimeConfig.d.ts +1 -0
- package/dist/proxy/runtimeConfig.js +20 -5
- package/dist/proxy/socketWorkerRuntime.js +41 -13
- package/dist/server/routes/claudeProxyRoutes.js +63 -75
- package/dist/tools/toolDiscovery.d.ts +25 -3
- package/dist/tools/toolDiscovery.js +76 -4
- package/dist/types/proxy.d.ts +31 -1
- package/dist/types/toolResolution.d.ts +10 -0
- 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
|
-
|
|
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
|
|
168
|
+
const envFileContent = await readFile(options.envFilePath, "utf8");
|
|
168
169
|
const { parse } = await import("dotenv");
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
219
|
+
takePendingSocket(socketId)?.destroy();
|
|
220
|
+
drainWhenPendingSettled();
|
|
190
221
|
}
|
|
191
222
|
});
|
|
192
223
|
}
|
|
193
224
|
catch {
|
|
194
|
-
|
|
195
|
-
|
|
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 =
|
|
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
|
|
263
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
2783
|
-
const {
|
|
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
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
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:
|
|
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
|
|
10
|
-
* record between agent-loop steps
|
|
11
|
-
*
|
|
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
|
|
10
|
-
* record between agent-loop steps
|
|
11
|
-
*
|
|
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.
|
|
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
|
});
|