@juspay/neurolink 13.8.6 → 13.8.7
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 +7 -2
- package/dist/auth/anthropicOAuth.js +7 -6
- package/dist/auth/codexOAuth.js +2 -1
- package/dist/browser/neurolink.min.js +487 -487
- package/dist/cli/commands/proxy.js +26 -4
- package/dist/mcp/mcpClientFactory.d.ts +12 -1
- package/dist/mcp/mcpClientFactory.js +87 -8
- package/dist/providers/anthropic/client.js +2 -2
- package/dist/proxy/accountUsage.js +2 -1
- package/dist/proxy/codexAccountUsage.js +2 -1
- package/dist/proxy/providerTransportCoordinator.d.ts +6 -0
- package/dist/proxy/providerTransportCoordinator.js +34 -1
- package/dist/proxy/proxyDispatcher.js +2 -2
- package/dist/proxy/proxyFetch.d.ts +9 -3
- package/dist/proxy/proxyFetch.js +31 -9
- package/dist/proxy/routingPolicy.d.ts +1 -1
- package/dist/proxy/snapshotPersistence.d.ts +6 -0
- package/dist/proxy/snapshotPersistence.js +10 -0
- package/dist/proxy/tokenRefresh.d.ts +2 -0
- package/dist/proxy/tokenRefresh.js +19 -3
- package/dist/proxy/vertexAnthropicFallback.js +2 -2
- package/dist/server/routes/claudeProxyRoutes.js +107 -26
- package/dist/server/routes/codexProxyRoutes.js +3 -2
- package/dist/types/proxy.d.ts +8 -0
- package/docs-site/static/search-index.json +2 -2
- package/package.json +2 -1
|
@@ -2,6 +2,7 @@ import { initializeProxyOtelLogs, routeProxyConsoleToOtel, shutdownProxyOtelLogs
|
|
|
2
2
|
import { registerCliShutdownOwner } from "../lifecycle.js";
|
|
3
3
|
import { writeFileAtomic } from "../proxy-clients/snapshot.js";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { Server as HttpServer } from "node:http";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { stripVTControlCharacters } from "node:util";
|
|
@@ -67,6 +68,19 @@ const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/obse
|
|
|
67
68
|
*/
|
|
68
69
|
const gatedShareRequests = new WeakSet();
|
|
69
70
|
const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
71
|
+
/** Reverse proxies in front of this server (cloudflared keeps idle origin
|
|
72
|
+
* connections for 90s) reuse sockets longer than Node's 5s default. A socket
|
|
73
|
+
* the server closes while the tunnel sends a POST on it surfaces as a 502,
|
|
74
|
+
* because a POST is never retried, so the server must be the side that waits
|
|
75
|
+
* longer. headersTimeout has to exceed it, or Node cuts idle sockets early. */
|
|
76
|
+
const PROXY_SERVER_KEEP_ALIVE_TIMEOUT_MS = 120_000;
|
|
77
|
+
const PROXY_SERVER_HEADERS_TIMEOUT_MS = 125_000;
|
|
78
|
+
function extendProxyServerKeepAlive(server) {
|
|
79
|
+
if (server instanceof HttpServer) {
|
|
80
|
+
server.keepAliveTimeout = PROXY_SERVER_KEEP_ALIVE_TIMEOUT_MS;
|
|
81
|
+
server.headersTimeout = PROXY_SERVER_HEADERS_TIMEOUT_MS;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
70
84
|
// Allow the normal 30-second worker drain plus startup settlement, leaving
|
|
71
85
|
// telemetry and scheduling headroom inside launchd's 45-second stop budget.
|
|
72
86
|
const PROXY_SUPERVISOR_DRAIN_TIMEOUT_MS = 35_000;
|
|
@@ -2399,7 +2413,7 @@ function recordBackgroundRefreshFailure(key) {
|
|
|
2399
2413
|
});
|
|
2400
2414
|
}
|
|
2401
2415
|
async function refreshProxyTokensInBackground(accountAllowlist) {
|
|
2402
|
-
const {
|
|
2416
|
+
const { needsBackgroundRefresh, refreshToken, persistTokens, isPermanentRefreshFailure, } = await import("../../proxy/tokenRefresh.js");
|
|
2403
2417
|
const { tokenStore } = await import("../../auth/tokenStore.js");
|
|
2404
2418
|
let storedAnthropicAccountCount;
|
|
2405
2419
|
try {
|
|
@@ -2422,7 +2436,7 @@ async function refreshProxyTokensInBackground(accountAllowlist) {
|
|
|
2422
2436
|
refreshToken: tokens.refreshToken,
|
|
2423
2437
|
expiresAt: tokens.expiresAt,
|
|
2424
2438
|
};
|
|
2425
|
-
if (!
|
|
2439
|
+
if (!needsBackgroundRefresh(account)) {
|
|
2426
2440
|
backgroundRefreshFailures.delete(key);
|
|
2427
2441
|
continue;
|
|
2428
2442
|
}
|
|
@@ -2473,7 +2487,7 @@ async function refreshProxyTokensInBackground(accountAllowlist) {
|
|
|
2473
2487
|
expiresAt: creds.oauth.expiresAt,
|
|
2474
2488
|
};
|
|
2475
2489
|
const legacyKey = LEGACY_ANTHROPIC_ACCOUNT_KEY;
|
|
2476
|
-
if (!
|
|
2490
|
+
if (!needsBackgroundRefresh(account)) {
|
|
2477
2491
|
backgroundRefreshFailures.delete(legacyKey);
|
|
2478
2492
|
return;
|
|
2479
2493
|
}
|
|
@@ -2607,12 +2621,14 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2607
2621
|
}
|
|
2608
2622
|
const usageStatsFlush = import("../../proxy/usageStats.js").then(({ flushUsageStats }) => flushUsageStats());
|
|
2609
2623
|
const requestLogsFlush = import("../../proxy/requestLogger.js").then(({ flushRequestLogs }) => flushRequestLogs());
|
|
2610
|
-
const
|
|
2624
|
+
const snapshotWritesFlush = import("../../proxy/snapshotPersistence.js").then(({ flushSnapshotWrites }) => flushSnapshotWrites());
|
|
2625
|
+
const [usageStatsFlushResult, lifecycleFlushResult, requestLogsFlushResult, snapshotWritesFlushResult,] = await Promise.allSettled([
|
|
2611
2626
|
withTimeout(usageStatsFlush, PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy usage statistics during shutdown"),
|
|
2612
2627
|
withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown"),
|
|
2613
2628
|
// This flush owns a separate bounded budget covering the body worker
|
|
2614
2629
|
// deadline plus index/OTLP publication; do not truncate it to five seconds.
|
|
2615
2630
|
requestLogsFlush,
|
|
2631
|
+
withTimeout(snapshotWritesFlush, PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy snapshot writes during shutdown"),
|
|
2616
2632
|
]);
|
|
2617
2633
|
if (usageStatsFlushResult.status === "rejected") {
|
|
2618
2634
|
const error = usageStatsFlushResult.reason;
|
|
@@ -2626,6 +2642,10 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2626
2642
|
const error = requestLogsFlushResult.reason;
|
|
2627
2643
|
logger.warn(`[proxy] request log flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
|
|
2628
2644
|
}
|
|
2645
|
+
if (snapshotWritesFlushResult.status === "rejected") {
|
|
2646
|
+
const error = snapshotWritesFlushResult.reason;
|
|
2647
|
+
logger.warn(`[proxy] snapshot write flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
|
|
2648
|
+
}
|
|
2629
2649
|
try {
|
|
2630
2650
|
const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../services/server/ai/observability/instrumentation.js");
|
|
2631
2651
|
await withShutdownDeadline((async () => {
|
|
@@ -2678,6 +2698,7 @@ async function startProxyRuntime(params) {
|
|
|
2678
2698
|
port: params.port,
|
|
2679
2699
|
hostname: params.host,
|
|
2680
2700
|
});
|
|
2701
|
+
extendProxyServerKeepAlive(server);
|
|
2681
2702
|
// The gate-only listener. It exists only while this node lends something, and
|
|
2682
2703
|
// it is the port an operator exposes: the main port keeps serving the
|
|
2683
2704
|
// operator's own untokened client exactly as before.
|
|
@@ -2712,6 +2733,7 @@ async function startProxyRuntime(params) {
|
|
|
2712
2733
|
listening = true;
|
|
2713
2734
|
resolve(started);
|
|
2714
2735
|
});
|
|
2736
|
+
extendProxyServerKeepAlive(started);
|
|
2715
2737
|
started.on("error", (error) => {
|
|
2716
2738
|
if (!listening) {
|
|
2717
2739
|
reject(error);
|
|
@@ -11,8 +11,19 @@ import type { ChildProcess } from "child_process";
|
|
|
11
11
|
import type { MCPTransportType, MCPServerInfo, MCPClientResult } from "../types/index.js";
|
|
12
12
|
/**
|
|
13
13
|
* Synchronous on purpose: it is the one thing that can run inside the
|
|
14
|
-
* `exit` event, and
|
|
14
|
+
* `exit` event, and signals are delivered by the kernel even though this
|
|
15
15
|
* process is already on its way out.
|
|
16
|
+
*
|
|
17
|
+
* A live server is signalled through `child.kill()` whenever its transport
|
|
18
|
+
* still holds the child, for the reason given on `closingStdioChildren`:
|
|
19
|
+
* between a child's exit and its `close` event `transport.pid` still reads the
|
|
20
|
+
* old number, which the kernel may already have handed to another process.
|
|
21
|
+
* `transport.pid` remains only for an SDK that no longer exposes the child.
|
|
22
|
+
*
|
|
23
|
+
* A server whose close() was already under way gets SIGKILL. The SDK had
|
|
24
|
+
* committed to stopping it and would have escalated to SIGKILL within four
|
|
25
|
+
* seconds; the `exit` event cannot wait for that, so a server that ignores
|
|
26
|
+
* SIGTERM would otherwise be left running.
|
|
16
27
|
*/
|
|
17
28
|
export declare const terminateLiveStdioServers: () => void;
|
|
18
29
|
/**
|
|
@@ -78,29 +78,100 @@ const stderrTails = new WeakMap();
|
|
|
78
78
|
* has exited, so a server the manager closed is not signalled again.
|
|
79
79
|
*/
|
|
80
80
|
const liveStdioTransports = new Set();
|
|
81
|
+
/**
|
|
82
|
+
* Children of transports whose `close()` has started and whose process has
|
|
83
|
+
* not yet closed.
|
|
84
|
+
*
|
|
85
|
+
* `close()` drops its reference to the child before it does anything else, so
|
|
86
|
+
* from then on `transport.pid` reads null, and a transport in that state is
|
|
87
|
+
* invisible to the set above. The close lasts up to four seconds for a server
|
|
88
|
+
* that ignores EOF and SIGTERM (`stdin.end()`, 2s, SIGTERM, 2s, SIGKILL). A
|
|
89
|
+
* host that calls `process.exit()` inside that window never reaches the SDK's
|
|
90
|
+
* escalation, so the exit hook signals the child held here.
|
|
91
|
+
*
|
|
92
|
+
* The child is held as a `ChildProcess` and signalled with `child.kill()`,
|
|
93
|
+
* never as a pid handed to `process.kill()`. Node clears the child's handle in
|
|
94
|
+
* the same callback in which libuv reaps it, and `kill()` signals only through
|
|
95
|
+
* that handle, so it does nothing for a child that has exited. The kernel
|
|
96
|
+
* cannot reuse a pid before the reap, which means this can never reach a
|
|
97
|
+
* process that merely inherited the number. A child leaves this set when it
|
|
98
|
+
* exits or closes, whichever comes first.
|
|
99
|
+
*/
|
|
100
|
+
const closingStdioChildren = new Set();
|
|
81
101
|
let stdioExitHookInstalled = false;
|
|
102
|
+
let stdioChildProcessClass;
|
|
103
|
+
/**
|
|
104
|
+
* The SDK exposes no handle on the child; `pid` and `stderr` are its whole
|
|
105
|
+
* public surface. `_process` is private, so it is read through `Reflect.get`
|
|
106
|
+
* and used only if it is a real `ChildProcess`. An SDK release that renames or
|
|
107
|
+
* reshapes it yields undefined here.
|
|
108
|
+
*/
|
|
109
|
+
const stdioChildOf = (transport, childProcessClass) => {
|
|
110
|
+
const candidate = Reflect.get(transport, "_process");
|
|
111
|
+
return candidate instanceof childProcessClass ? candidate : undefined;
|
|
112
|
+
};
|
|
82
113
|
/**
|
|
83
114
|
* Synchronous on purpose: it is the one thing that can run inside the
|
|
84
|
-
* `exit` event, and
|
|
115
|
+
* `exit` event, and signals are delivered by the kernel even though this
|
|
85
116
|
* process is already on its way out.
|
|
117
|
+
*
|
|
118
|
+
* A live server is signalled through `child.kill()` whenever its transport
|
|
119
|
+
* still holds the child, for the reason given on `closingStdioChildren`:
|
|
120
|
+
* between a child's exit and its `close` event `transport.pid` still reads the
|
|
121
|
+
* old number, which the kernel may already have handed to another process.
|
|
122
|
+
* `transport.pid` remains only for an SDK that no longer exposes the child.
|
|
123
|
+
*
|
|
124
|
+
* A server whose close() was already under way gets SIGKILL. The SDK had
|
|
125
|
+
* committed to stopping it and would have escalated to SIGKILL within four
|
|
126
|
+
* seconds; the `exit` event cannot wait for that, so a server that ignores
|
|
127
|
+
* SIGTERM would otherwise be left running.
|
|
86
128
|
*/
|
|
87
129
|
export const terminateLiveStdioServers = () => {
|
|
88
130
|
for (const transport of liveStdioTransports) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
131
|
+
const child = stdioChildProcessClass && stdioChildOf(transport, stdioChildProcessClass);
|
|
132
|
+
try {
|
|
133
|
+
if (child) {
|
|
134
|
+
child.kill("SIGTERM");
|
|
92
135
|
}
|
|
93
|
-
|
|
94
|
-
|
|
136
|
+
else if ("pid" in transport && typeof transport.pid === "number") {
|
|
137
|
+
process.kill(transport.pid, "SIGTERM");
|
|
95
138
|
}
|
|
96
139
|
}
|
|
140
|
+
catch {
|
|
141
|
+
// Already gone.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const child of closingStdioChildren) {
|
|
145
|
+
try {
|
|
146
|
+
child.kill("SIGKILL");
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Already gone.
|
|
150
|
+
}
|
|
97
151
|
}
|
|
98
152
|
};
|
|
99
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Without the child, an SDK release that hides it leaves the close window as
|
|
155
|
+
* open as it was before this set existed.
|
|
156
|
+
*/
|
|
157
|
+
const holdChildWhileClosing = (transport, childProcessClass) => {
|
|
158
|
+
const child = stdioChildOf(transport, childProcessClass);
|
|
159
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
closingStdioChildren.add(child);
|
|
163
|
+
const release = () => {
|
|
164
|
+
closingStdioChildren.delete(child);
|
|
165
|
+
};
|
|
166
|
+
child.once("exit", release);
|
|
167
|
+
child.once("close", release);
|
|
168
|
+
};
|
|
169
|
+
const trackStdioTransport = (transport, childProcessClass) => {
|
|
100
170
|
if (!stdioExitHookInstalled) {
|
|
101
171
|
stdioExitHookInstalled = true;
|
|
102
172
|
process.on("exit", terminateLiveStdioServers);
|
|
103
173
|
}
|
|
174
|
+
stdioChildProcessClass = childProcessClass;
|
|
104
175
|
liveStdioTransports.add(transport);
|
|
105
176
|
// Client.connect() chains an onclose that is already set rather than
|
|
106
177
|
// replacing it, and the manager listens on client.onclose, so this one
|
|
@@ -109,6 +180,13 @@ const trackStdioTransport = (transport) => {
|
|
|
109
180
|
transport.onclose = () => {
|
|
110
181
|
liveStdioTransports.delete(transport);
|
|
111
182
|
};
|
|
183
|
+
// The child has to be read before close() starts, which is the last moment
|
|
184
|
+
// the transport still has it.
|
|
185
|
+
const closeTransport = transport.close.bind(transport);
|
|
186
|
+
transport.close = async () => {
|
|
187
|
+
holdChildWhileClosing(transport, childProcessClass);
|
|
188
|
+
await closeTransport();
|
|
189
|
+
};
|
|
112
190
|
};
|
|
113
191
|
/**
|
|
114
192
|
* MCPClientFactory
|
|
@@ -354,6 +432,7 @@ export class MCPClientFactory {
|
|
|
354
432
|
throw new Error(`Command is required for stdio transport`);
|
|
355
433
|
}
|
|
356
434
|
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
435
|
+
const { ChildProcess: childProcessClass } = await import("node:child_process");
|
|
357
436
|
const transport = new StdioClientTransport({
|
|
358
437
|
command: config.command,
|
|
359
438
|
args: config.args || [],
|
|
@@ -366,7 +445,7 @@ export class MCPClientFactory {
|
|
|
366
445
|
cwd: config.cwd,
|
|
367
446
|
stderr: "pipe",
|
|
368
447
|
});
|
|
369
|
-
trackStdioTransport(transport);
|
|
448
|
+
trackStdioTransport(transport, childProcessClass);
|
|
370
449
|
const tail = new StderrTail();
|
|
371
450
|
stderrTails.set(transport, tail);
|
|
372
451
|
transport.stderr?.on("data", (chunk) => {
|
|
@@ -10,7 +10,7 @@ import { DEFAULT_MAX_STEPS } from "../../core/constants.js";
|
|
|
10
10
|
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
|
-
import { createProxyFetch } from "../../proxy/proxyFetch.js";
|
|
13
|
+
import { createProxyFetch, fetchProxyAware } from "../../proxy/proxyFetch.js";
|
|
14
14
|
import { getCapturedLimitSnapshot, getCapturedResponseHeaders, logClaudeLimitSnapshot, runInLimitCaptureScope, setLimitSpanAttributes, withLimitCapture, wrapFetchWithLimitCapture, } from "./rateLimitCapture.js";
|
|
15
15
|
import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
|
|
16
16
|
import { classifyProviderError, messageNamesStatus, } from "../../utils/errorClassifier.js";
|
|
@@ -886,7 +886,7 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
886
886
|
// cosmetic choice. If Anthropic ever publishes a separate UA for
|
|
887
887
|
// third-party OAuth clients, switch to that. See `auth/anthropicOAuth.ts`
|
|
888
888
|
// for the source of `CLAUDE_CLI_USER_AGENT` / `CLAUDE_CODE_CLIENT_ID`.
|
|
889
|
-
const response = await
|
|
889
|
+
const response = await fetchProxyAware(ANTHROPIC_TOKEN_URL, {
|
|
890
890
|
method: "POST",
|
|
891
891
|
headers: {
|
|
892
892
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -19,6 +19,7 @@ import { tokenStore } from "../auth/tokenStore.js";
|
|
|
19
19
|
import { CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../auth/anthropicOAuth.js";
|
|
20
20
|
import { needsRefresh, refreshTokenFromLatest, isPermanentRefreshFailure, persistTokens, } from "./tokenRefresh.js";
|
|
21
21
|
import { isAccountAllowed } from "./accountSelection.js";
|
|
22
|
+
import { fetchProxyAware } from "./proxyFetch.js";
|
|
22
23
|
export const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
23
24
|
const USAGE_FETCH_TIMEOUT_MS = 10_000;
|
|
24
25
|
/**
|
|
@@ -57,7 +58,7 @@ export async function listAnthropicAccountsForUsage(allowlist) {
|
|
|
57
58
|
}
|
|
58
59
|
async function requestUsage(token) {
|
|
59
60
|
try {
|
|
60
|
-
const response = await
|
|
61
|
+
const response = await fetchProxyAware(ANTHROPIC_USAGE_URL, {
|
|
61
62
|
method: "GET",
|
|
62
63
|
headers: {
|
|
63
64
|
authorization: `Bearer ${token}`,
|
|
@@ -10,6 +10,7 @@ import { tokenStore } from "../auth/tokenStore.js";
|
|
|
10
10
|
import { normalizeAnthropicAccountKey } from "./accountSelection.js";
|
|
11
11
|
import { CODEX_USAGE_URL, CODEX_USER_AGENT, decodeCodexAccessToken, resolveCodexAccountId, } from "../auth/codexOAuth.js";
|
|
12
12
|
import { logger } from "../utils/logger.js";
|
|
13
|
+
import { fetchProxyAware } from "./proxyFetch.js";
|
|
13
14
|
export const CODEX_ACCOUNT_PREFIX = "codex:";
|
|
14
15
|
/**
|
|
15
16
|
* Native fallback legs (e.g. Claude-on-Vertex) key their usage rows as
|
|
@@ -207,7 +208,7 @@ export async function fetchCodexAccountUsage(account, options = {}) {
|
|
|
207
208
|
}
|
|
208
209
|
const accountId = resolveCodexAccountId(account.token);
|
|
209
210
|
try {
|
|
210
|
-
const response = await
|
|
211
|
+
const response = await fetchProxyAware(CODEX_USAGE_URL, {
|
|
211
212
|
method: "GET",
|
|
212
213
|
headers: {
|
|
213
214
|
Authorization: `Bearer ${account.token}`,
|
|
@@ -12,6 +12,12 @@ export declare class ProviderTransportCoordinator {
|
|
|
12
12
|
acquire(signal?: AbortSignal): Promise<ProxyProviderTransportPermit>;
|
|
13
13
|
reportSuccess(permit: ProxyProviderTransportPermit): void;
|
|
14
14
|
reportTransportFailure(errorCode: string | undefined, transportScope: "shared_provider_transport" | "connection_transport", permit: ProxyProviderTransportPermit, connectPhase?: boolean): void;
|
|
15
|
+
/**
|
|
16
|
+
* A connect to the provider just completed. That is the transport recovering,
|
|
17
|
+
* and it is known long before a probe's response headers, which for a
|
|
18
|
+
* non-streaming request arrive only after the whole generation.
|
|
19
|
+
*/
|
|
20
|
+
reportConnectionEstablished(): void;
|
|
15
21
|
reportProbeAbandoned(permit: ProxyProviderTransportPermit): void;
|
|
16
22
|
clear(): void;
|
|
17
23
|
private wait;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { TimeoutError, withTimeout } from "../utils/async/withTimeout.js";
|
|
2
2
|
const SHARED_TRANSPORT_BACKOFF_MS = 250;
|
|
3
|
-
|
|
3
|
+
/** Upper bound on how long a request parks behind someone else's probe. A
|
|
4
|
+
* waiter that times out gets its own bounded retry, so this only caps the
|
|
5
|
+
* stall a healthy request can inherit from an unrelated slow one. */
|
|
6
|
+
const RECOVERY_PROBE_WAIT_TIMEOUT_MS = 3_000;
|
|
4
7
|
export class ProviderTransportCoordinator {
|
|
5
8
|
generation = 0;
|
|
6
9
|
degraded = false;
|
|
@@ -72,6 +75,17 @@ export class ProviderTransportCoordinator {
|
|
|
72
75
|
if (permit.probe && this.probe?.generation !== permit.generation) {
|
|
73
76
|
return;
|
|
74
77
|
}
|
|
78
|
+
if (transportScope === "connection_transport") {
|
|
79
|
+
// One socket failing (a lost SYN, a reset) says nothing about the
|
|
80
|
+
// provider. The failing request already retries on its own budget, so
|
|
81
|
+
// parking every other request behind a probe only spreads the stall.
|
|
82
|
+
if (permit.probe && this.probe?.generation === permit.generation) {
|
|
83
|
+
this.probe.resolve("abandoned");
|
|
84
|
+
this.probe = undefined;
|
|
85
|
+
this.generation += 1;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
75
89
|
this.degraded = true;
|
|
76
90
|
this.backoffUntil = Date.now() + SHARED_TRANSPORT_BACKOFF_MS;
|
|
77
91
|
this.lastErrorCode = errorCode ?? null;
|
|
@@ -83,6 +97,25 @@ export class ProviderTransportCoordinator {
|
|
|
83
97
|
}
|
|
84
98
|
this.generation += 1;
|
|
85
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* A connect to the provider just completed. That is the transport recovering,
|
|
102
|
+
* and it is known long before a probe's response headers, which for a
|
|
103
|
+
* non-streaming request arrive only after the whole generation.
|
|
104
|
+
*/
|
|
105
|
+
reportConnectionEstablished() {
|
|
106
|
+
if (!this.degraded) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.degraded = false;
|
|
110
|
+
this.backoffUntil = 0;
|
|
111
|
+
this.lastErrorCode = null;
|
|
112
|
+
this.lastTransportScope = "shared_provider_transport";
|
|
113
|
+
this.lastFailureConnectPhase = false;
|
|
114
|
+
if (this.probe) {
|
|
115
|
+
this.probe.resolve("recovered");
|
|
116
|
+
this.probe = undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
86
119
|
reportProbeAbandoned(permit) {
|
|
87
120
|
if (!permit.allowed ||
|
|
88
121
|
!permit.probe ||
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tuned global undici dispatcher for the proxy's upstream forwards.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Direct proxy-command fetches use the global `fetch` → undici global
|
|
5
|
+
* dispatcher. Undici keeps connections alive
|
|
6
6
|
* by default, but with a ~4s idle timeout: between Claude Code turns (the user
|
|
7
7
|
* reads output, a tool runs, the model thinks) the idle socket closes, so the
|
|
8
8
|
* next request opens a brand-new TCP connection. Under sustained use that is a
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
|
|
4
4
|
* Lightweight implementation extracted from research of major proxy packages
|
|
5
5
|
*/
|
|
6
|
-
import type { ProxyAgent } from "undici";
|
|
7
|
-
import type { GoogleGenAIHttpOptions } from "../types/index.js";
|
|
6
|
+
import type { Dispatcher, ProxyAgent } from "undici";
|
|
7
|
+
import type { GoogleGenAIHttpOptions, ProxyTimeouts } from "../types/index.js";
|
|
8
8
|
/**
|
|
9
9
|
* Classify a fetch failure as a transient network error worth retrying.
|
|
10
10
|
*
|
|
@@ -32,7 +32,13 @@ export declare function isTransientNetworkError(error: unknown): boolean;
|
|
|
32
32
|
* under proxy rules) gets no fallback to a direct connection from this: a proxy
|
|
33
33
|
* that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
|
|
34
34
|
*/
|
|
35
|
-
export declare function getProxyDispatcherForUrl(targetUrl: string): Promise<ProxyAgent | null>;
|
|
35
|
+
export declare function getProxyDispatcherForUrl(targetUrl: string, timeouts?: ProxyTimeouts): Promise<ProxyAgent | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Fetch for the proxy command's own outbound requests. A configured proxy is
|
|
38
|
+
* mandatory for its target: a failed proxy connection must never retry direct.
|
|
39
|
+
* The optional dispatcher preserves callers' direct-connection tuning.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fetchProxyAware(url: string | URL, init?: RequestInit, directDispatcher?: Dispatcher, proxyTimeouts?: ProxyTimeouts): Promise<Response>;
|
|
36
42
|
/**
|
|
37
43
|
* Create a proxy-aware fetch function with enhanced capabilities
|
|
38
44
|
* Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
|
package/dist/proxy/proxyFetch.js
CHANGED
|
@@ -331,7 +331,7 @@ function selectProxyUrl(targetUrl) {
|
|
|
331
331
|
/**
|
|
332
332
|
* Create appropriate proxy agent based on protocol
|
|
333
333
|
*/
|
|
334
|
-
async function createProxyAgent(proxyUrl) {
|
|
334
|
+
async function createProxyAgent(proxyUrl, timeouts = {}) {
|
|
335
335
|
const parsed = parseProxyUrl(proxyUrl);
|
|
336
336
|
logger.debug("[Proxy] Creating proxy agent", {
|
|
337
337
|
protocol: parsed.protocol,
|
|
@@ -344,7 +344,7 @@ async function createProxyAgent(proxyUrl) {
|
|
|
344
344
|
case "https:": {
|
|
345
345
|
// Use existing undici ProxyAgent for HTTP/HTTPS
|
|
346
346
|
const { ProxyAgent } = await import("undici");
|
|
347
|
-
return new ProxyAgent(proxyUrl);
|
|
347
|
+
return new ProxyAgent({ uri: proxyUrl, ...timeouts });
|
|
348
348
|
}
|
|
349
349
|
case "socks4:":
|
|
350
350
|
case "socks5:": {
|
|
@@ -358,19 +358,20 @@ async function createProxyAgent(proxyUrl) {
|
|
|
358
358
|
}
|
|
359
359
|
/**
|
|
360
360
|
* One ProxyAgent per proxy URL for the whole process, so connections to the
|
|
361
|
-
* proxy are pooled and an agent is not rebuilt per request.
|
|
362
|
-
*
|
|
361
|
+
* proxy are pooled and an agent is not rebuilt per request. Hashing the full
|
|
362
|
+
* URL keeps credentials out of map keys without mixing distinct proxy logins.
|
|
363
363
|
*/
|
|
364
|
-
async function getOrCreateProxyAgent(proxyUrl) {
|
|
364
|
+
async function getOrCreateProxyAgent(proxyUrl, timeouts = {}) {
|
|
365
365
|
const globalWithCache = globalThis;
|
|
366
366
|
if (!globalWithCache.__NL_PROXY_AGENT_CACHE__) {
|
|
367
367
|
globalWithCache.__NL_PROXY_AGENT_CACHE__ = new Map();
|
|
368
368
|
}
|
|
369
369
|
const agentCache = globalWithCache.__NL_PROXY_AGENT_CACHE__;
|
|
370
370
|
const cacheKey = createHash("sha256")
|
|
371
|
-
.update(
|
|
371
|
+
.update(proxyUrl)
|
|
372
|
+
.update(JSON.stringify(timeouts))
|
|
372
373
|
.digest("hex");
|
|
373
|
-
const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl));
|
|
374
|
+
const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl, timeouts));
|
|
374
375
|
agentCache.set(cacheKey, dispatcher);
|
|
375
376
|
return dispatcher;
|
|
376
377
|
}
|
|
@@ -384,9 +385,30 @@ async function getOrCreateProxyAgent(proxyUrl) {
|
|
|
384
385
|
* under proxy rules) gets no fallback to a direct connection from this: a proxy
|
|
385
386
|
* that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
|
|
386
387
|
*/
|
|
387
|
-
export async function getProxyDispatcherForUrl(targetUrl) {
|
|
388
|
+
export async function getProxyDispatcherForUrl(targetUrl, timeouts = {}) {
|
|
388
389
|
const proxyUrl = selectProxyUrl(targetUrl);
|
|
389
|
-
return proxyUrl ? getOrCreateProxyAgent(proxyUrl) : null;
|
|
390
|
+
return proxyUrl ? getOrCreateProxyAgent(proxyUrl, timeouts) : null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Fetch for the proxy command's own outbound requests. A configured proxy is
|
|
394
|
+
* mandatory for its target: a failed proxy connection must never retry direct.
|
|
395
|
+
* The optional dispatcher preserves callers' direct-connection tuning.
|
|
396
|
+
*/
|
|
397
|
+
export async function fetchProxyAware(url, init, directDispatcher, proxyTimeouts) {
|
|
398
|
+
const proxyDispatcher = await getProxyDispatcherForUrl(url.toString(), proxyTimeouts);
|
|
399
|
+
if (!proxyDispatcher) {
|
|
400
|
+
return fetch(url, directDispatcher
|
|
401
|
+
? { ...init, dispatcher: directDispatcher }
|
|
402
|
+
: init);
|
|
403
|
+
}
|
|
404
|
+
const { fetch: undiciFetch } = await import("undici");
|
|
405
|
+
// Undici and lib.dom describe the same runtime Response with different
|
|
406
|
+
// iterator types.
|
|
407
|
+
const response = (await undiciFetch(url, {
|
|
408
|
+
...init,
|
|
409
|
+
dispatcher: proxyDispatcher,
|
|
410
|
+
}));
|
|
411
|
+
return response;
|
|
390
412
|
}
|
|
391
413
|
function sanitizeProxyUrl(url) {
|
|
392
414
|
return maskProxyUrl(url) ?? "NOT_SET";
|
|
@@ -11,7 +11,7 @@ export declare function inferClaudeProxyModelTier(modelName: string): ClaudeProx
|
|
|
11
11
|
export declare function buildProxyTranslationPlan(primary: {
|
|
12
12
|
provider: string;
|
|
13
13
|
model?: string;
|
|
14
|
-
}, fallbackChain: FallbackEntry[], requestedModel: string, _parsed
|
|
14
|
+
}, fallbackChain: FallbackEntry[], requestedModel: string, _parsed?: ParsedClaudeRequest, allowAutoFallback?: boolean): ProxyTranslationPlan;
|
|
15
15
|
/**
|
|
16
16
|
* Parse the retry-after header from an upstream 429 response.
|
|
17
17
|
* Returns milliseconds to wait, or 0 if no valid header present.
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export declare function writeJsonSnapshotAtomically(targetPath: string, data: unknown, mode?: number): Promise<void>;
|
|
2
|
+
/** Settles every queued snapshot write. Shutdown awaits this so a write a
|
|
3
|
+
* request left running in the background still lands before exit. It drains
|
|
4
|
+
* until the queue stays empty, because a producer that outlived the listener
|
|
5
|
+
* close can queue another write while earlier ones settle; the caller bounds
|
|
6
|
+
* the whole drain with its own timeout. */
|
|
7
|
+
export declare function flushSnapshotWrites(): Promise<void>;
|
|
2
8
|
export declare function clearSnapshotWriteLocksForTests(): void;
|
|
@@ -35,6 +35,16 @@ export async function writeJsonSnapshotAtomically(targetPath, data, mode = 0o600
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
/** Settles every queued snapshot write. Shutdown awaits this so a write a
|
|
39
|
+
* request left running in the background still lands before exit. It drains
|
|
40
|
+
* until the queue stays empty, because a producer that outlived the listener
|
|
41
|
+
* close can queue another write while earlier ones settle; the caller bounds
|
|
42
|
+
* the whole drain with its own timeout. */
|
|
43
|
+
export async function flushSnapshotWrites() {
|
|
44
|
+
while (writeLocks.size > 0) {
|
|
45
|
+
await Promise.allSettled([...writeLocks.values()]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
38
48
|
export function clearSnapshotWriteLocksForTests() {
|
|
39
49
|
writeLocks.clear();
|
|
40
50
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RefreshableAccount, RefreshResult, TokenPersistTarget } from "../types/index.js";
|
|
2
2
|
export declare function needsRefresh(account: RefreshableAccount): boolean;
|
|
3
|
+
export declare function needsBackgroundRefresh(account: RefreshableAccount): boolean;
|
|
4
|
+
export declare function needsInlineRefresh(account: RefreshableAccount): boolean;
|
|
3
5
|
export declare function isPermanentRefreshFailure(result: RefreshResult): boolean;
|
|
4
6
|
/**
|
|
5
7
|
* Serialize refreshes by refresh-token value. OAuth refresh tokens may rotate,
|
|
@@ -4,17 +4,33 @@ import { ANTHROPIC_TOKEN_URL, ANTHROPIC_TOKEN_URL_FALLBACK, CLAUDE_CLI_USER_AGEN
|
|
|
4
4
|
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
5
5
|
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
6
6
|
import { redactUrlForError } from "../utils/logSanitize.js";
|
|
7
|
+
import { fetchProxyAware } from "./proxyFetch.js";
|
|
7
8
|
const BUFFER_MS = 5 * 60 * 1000;
|
|
9
|
+
/** The background sweep runs every 30s and refreshes this far ahead, so a
|
|
10
|
+
* token is normally renewed long before a live request would have to. */
|
|
11
|
+
const BACKGROUND_REFRESH_BUFFER_MS = 10 * 60 * 1000;
|
|
12
|
+
/** A request refreshes inline only this close to expiry, i.e. when the sweep
|
|
13
|
+
* has not managed to, so the refresh round trip stays off the hot path. */
|
|
14
|
+
const INLINE_REFRESH_BUFFER_MS = 2 * 60 * 1000;
|
|
8
15
|
const SUCCESS_CACHE_MS = 60_000;
|
|
9
16
|
// Bound the best-effort persisted-credential reconciliation read so a hung or
|
|
10
17
|
// slow token-store decrypt can never stall (or abort) a live token refresh.
|
|
11
18
|
const PEEK_TOKENS_TIMEOUT_MS = 2_000;
|
|
12
19
|
const refreshesInFlight = new Map();
|
|
13
|
-
|
|
20
|
+
function expiresWithin(account, bufferMs) {
|
|
14
21
|
return !!(account.expiresAt &&
|
|
15
|
-
account.expiresAt <= Date.now() +
|
|
22
|
+
account.expiresAt <= Date.now() + bufferMs &&
|
|
16
23
|
account.refreshToken);
|
|
17
24
|
}
|
|
25
|
+
export function needsRefresh(account) {
|
|
26
|
+
return expiresWithin(account, BUFFER_MS);
|
|
27
|
+
}
|
|
28
|
+
export function needsBackgroundRefresh(account) {
|
|
29
|
+
return expiresWithin(account, BACKGROUND_REFRESH_BUFFER_MS);
|
|
30
|
+
}
|
|
31
|
+
export function needsInlineRefresh(account) {
|
|
32
|
+
return expiresWithin(account, INLINE_REFRESH_BUFFER_MS);
|
|
33
|
+
}
|
|
18
34
|
function isRefreshCredentialRejection(status) {
|
|
19
35
|
return status === 400 || status === 401 || status === 403 || status === 404;
|
|
20
36
|
}
|
|
@@ -43,7 +59,7 @@ async function performTokenRefresh(account) {
|
|
|
43
59
|
let terminalFailure;
|
|
44
60
|
for (const url of urls) {
|
|
45
61
|
try {
|
|
46
|
-
const resp = await
|
|
62
|
+
const resp = await fetchProxyAware(url, {
|
|
47
63
|
method: "POST",
|
|
48
64
|
headers,
|
|
49
65
|
body: requestBody,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { logger } from "../utils/logger.js";
|
|
16
16
|
import { claudeDisabledThinkingReplacement, modelSupportsForcedToolChoice, } from "../models/modelRegistry.js";
|
|
17
|
-
import { isTransientNetworkError } from "./proxyFetch.js";
|
|
17
|
+
import { fetchProxyAware, isTransientNetworkError } from "./proxyFetch.js";
|
|
18
18
|
import { readCacheCreation1hTokens } from "./proxyTokenUsage.js";
|
|
19
19
|
/** Vertex pins the Anthropic API version in the body, not a header. */
|
|
20
20
|
const VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
|
|
@@ -552,7 +552,7 @@ export async function dispatchVertexAnthropicPassthrough(request) {
|
|
|
552
552
|
model: request.model,
|
|
553
553
|
stream,
|
|
554
554
|
});
|
|
555
|
-
return
|
|
555
|
+
return fetchProxyAware(url, {
|
|
556
556
|
method: "POST",
|
|
557
557
|
headers: {
|
|
558
558
|
authorization: `Bearer ${token}`,
|