@juspay/neurolink 9.93.0 → 9.93.2
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 +379 -382
- package/dist/cli/commands/proxy.d.ts +3 -2
- package/dist/cli/commands/proxy.js +224 -66
- package/dist/cli/commands/proxyAnalyze.d.ts +3 -0
- package/dist/cli/commands/proxyAnalyze.js +147 -0
- package/dist/cli/parser.js +3 -1
- package/dist/cli/utils/serverUtils.js +4 -1
- package/dist/lib/proxy/globalInstaller.js +1 -1
- package/dist/lib/proxy/openaiFormat.js +1 -0
- package/dist/lib/proxy/proxyAnalysis.d.ts +3 -0
- package/dist/lib/proxy/proxyAnalysis.js +454 -0
- package/dist/lib/proxy/proxyHealth.d.ts +4 -0
- package/dist/lib/proxy/proxyHealth.js +21 -0
- package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
- package/dist/lib/proxy/sseInterceptor.js +7 -6
- package/dist/lib/proxy/streamOutcome.d.ts +3 -1
- package/dist/lib/proxy/streamOutcome.js +77 -0
- package/dist/lib/proxy/updateCoordinator.d.ts +7 -0
- package/dist/lib/proxy/updateCoordinator.js +60 -0
- package/dist/lib/proxy/updateState.d.ts +4 -0
- package/dist/lib/proxy/updateState.js +51 -0
- package/dist/lib/proxy/workerLog.d.ts +6 -0
- package/dist/lib/proxy/workerLog.js +42 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/lib/server/routes/claudeProxyRoutes.js +114 -17
- package/dist/lib/server/routes/openaiProxyRoutes.d.ts +9 -0
- package/dist/lib/server/routes/openaiProxyRoutes.js +16 -1
- package/dist/lib/types/cli.d.ts +7 -0
- package/dist/lib/types/proxy.d.ts +187 -0
- package/dist/proxy/globalInstaller.js +1 -1
- package/dist/proxy/openaiFormat.js +1 -0
- package/dist/proxy/proxyAnalysis.d.ts +3 -0
- package/dist/proxy/proxyAnalysis.js +453 -0
- package/dist/proxy/proxyHealth.d.ts +4 -0
- package/dist/proxy/proxyHealth.js +21 -0
- package/dist/proxy/sseInterceptor.d.ts +9 -1
- package/dist/proxy/sseInterceptor.js +7 -6
- package/dist/proxy/streamOutcome.d.ts +3 -1
- package/dist/proxy/streamOutcome.js +77 -0
- package/dist/proxy/updateCoordinator.d.ts +7 -0
- package/dist/proxy/updateCoordinator.js +59 -0
- package/dist/proxy/updateState.d.ts +4 -0
- package/dist/proxy/updateState.js +51 -0
- package/dist/proxy/workerLog.d.ts +6 -0
- package/dist/proxy/workerLog.js +41 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/server/routes/claudeProxyRoutes.js +114 -17
- package/dist/server/routes/openaiProxyRoutes.d.ts +9 -0
- package/dist/server/routes/openaiProxyRoutes.js +16 -1
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/proxy.d.ts +187 -0
- package/package.json +4 -1
|
@@ -1,3 +1,80 @@
|
|
|
1
|
+
import { extractSSEEvents } from "./sseInterceptor.js";
|
|
2
|
+
const STREAM_PREFLIGHT_MAX_BYTES = 64 * 1024;
|
|
3
|
+
const STREAM_PREFLIGHT_MAX_CHUNKS = 32;
|
|
4
|
+
function parseSSEError(data) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = JSON.parse(data);
|
|
7
|
+
return {
|
|
8
|
+
errorType: parsed.error?.type ?? parsed.type ?? "stream_error",
|
|
9
|
+
message: parsed.error?.message ?? parsed.message ?? "Upstream SSE error",
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {
|
|
14
|
+
errorType: "stream_error",
|
|
15
|
+
message: data.trim() || "Upstream SSE error",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function isSSEErrorEvent(event) {
|
|
20
|
+
if (event.event === "error") {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(event.data);
|
|
25
|
+
return parsed.type === "error" || parsed.error !== undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Hold only pre-commit SSE frames so immediate upstream errors remain retryable. */
|
|
32
|
+
export async function preflightAnthropicStream(reader) {
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let parseBuffer = "";
|
|
36
|
+
let totalBytes = 0;
|
|
37
|
+
while (totalBytes < STREAM_PREFLIGHT_MAX_BYTES &&
|
|
38
|
+
chunks.length < STREAM_PREFLIGHT_MAX_CHUNKS) {
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = await reader.read();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return { kind: "transport_error", chunks, error };
|
|
45
|
+
}
|
|
46
|
+
if (result.done) {
|
|
47
|
+
parseBuffer += decoder.decode();
|
|
48
|
+
const { events } = extractSSEEvents(parseBuffer);
|
|
49
|
+
const errorEvent = events.find(isSSEErrorEvent);
|
|
50
|
+
if (errorEvent) {
|
|
51
|
+
return { kind: "sse_error", chunks, ...parseSSEError(errorEvent.data) };
|
|
52
|
+
}
|
|
53
|
+
return chunks.length > 0
|
|
54
|
+
? { kind: "ready", chunks }
|
|
55
|
+
: { kind: "empty", chunks };
|
|
56
|
+
}
|
|
57
|
+
if (!result.value || result.value.length === 0) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
chunks.push(result.value);
|
|
61
|
+
totalBytes += result.value.byteLength;
|
|
62
|
+
parseBuffer += decoder.decode(result.value, { stream: true });
|
|
63
|
+
const parsed = extractSSEEvents(parseBuffer);
|
|
64
|
+
parseBuffer = parsed.remainder;
|
|
65
|
+
for (const event of parsed.events) {
|
|
66
|
+
if (isSSEErrorEvent(event)) {
|
|
67
|
+
return { kind: "sse_error", chunks, ...parseSSEError(event.data) };
|
|
68
|
+
}
|
|
69
|
+
if (event.event !== "ping" && (event.event || event.data)) {
|
|
70
|
+
return { kind: "ready", chunks };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return chunks.length > 0
|
|
75
|
+
? { kind: "ready", chunks }
|
|
76
|
+
: { kind: "empty", chunks };
|
|
77
|
+
}
|
|
1
78
|
export function createStreamTerminalOutcomeTracker() {
|
|
2
79
|
let settled = false;
|
|
3
80
|
let resolveOutcome;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProxyUpdateWindowOptions, ProxyUpdateWindowResult } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Prefer a naturally quiet window, then briefly drain new inference traffic.
|
|
4
|
+
* Existing requests are never interrupted; a bounded drain failure is returned
|
|
5
|
+
* to the caller so admission can be reopened and retried later.
|
|
6
|
+
*/
|
|
7
|
+
export declare function waitForProxyUpdateWindow(options: ProxyUpdateWindowOptions): Promise<ProxyUpdateWindowResult>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function isQuiet(activity, quietThresholdMs, nowMs) {
|
|
2
|
+
if (activity.activeRequests > 0) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
if (!activity.lastActivityAt) {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
const lastActivityMs = Date.parse(activity.lastActivityAt);
|
|
9
|
+
return (Number.isFinite(lastActivityMs) &&
|
|
10
|
+
nowMs - lastActivityMs >= quietThresholdMs);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Prefer a naturally quiet window, then briefly drain new inference traffic.
|
|
14
|
+
* Existing requests are never interrupted; a bounded drain failure is returned
|
|
15
|
+
* to the caller so admission can be reopened and retried later.
|
|
16
|
+
*/
|
|
17
|
+
export async function waitForProxyUpdateWindow(options) {
|
|
18
|
+
const now = options.now ?? Date.now;
|
|
19
|
+
const wait = options.sleep ??
|
|
20
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
21
|
+
const quietDeadline = now() + options.quietWaitMs;
|
|
22
|
+
while (now() < quietDeadline) {
|
|
23
|
+
if (options.isStopping()) {
|
|
24
|
+
return { ready: false, draining: false, reason: "stopping" };
|
|
25
|
+
}
|
|
26
|
+
if (!options.isParentAlive()) {
|
|
27
|
+
return { ready: false, draining: false, reason: "parent_stopped" };
|
|
28
|
+
}
|
|
29
|
+
const activity = await options.getActivity();
|
|
30
|
+
if (activity && isQuiet(activity, options.quietThresholdMs, now())) {
|
|
31
|
+
return { ready: true, draining: false };
|
|
32
|
+
}
|
|
33
|
+
options.onPhase?.("waiting_for_quiet", activity);
|
|
34
|
+
await wait(options.pollIntervalMs);
|
|
35
|
+
}
|
|
36
|
+
if (!(await options.setDraining(true))) {
|
|
37
|
+
// The control response may have been lost after the parent applied it.
|
|
38
|
+
// Treat state as unknown/draining so the caller always attempts resume.
|
|
39
|
+
return { ready: false, draining: true, reason: "drain_failed" };
|
|
40
|
+
}
|
|
41
|
+
const drainDeadline = now() + options.drainWaitMs;
|
|
42
|
+
while (now() < drainDeadline) {
|
|
43
|
+
if (options.isStopping()) {
|
|
44
|
+
return { ready: false, draining: true, reason: "stopping" };
|
|
45
|
+
}
|
|
46
|
+
if (!options.isParentAlive()) {
|
|
47
|
+
return { ready: false, draining: true, reason: "parent_stopped" };
|
|
48
|
+
}
|
|
49
|
+
const activity = await options.getActivity();
|
|
50
|
+
options.onPhase?.("draining", activity);
|
|
51
|
+
if (activity?.activeRequests === 0) {
|
|
52
|
+
return { ready: true, draining: true };
|
|
53
|
+
}
|
|
54
|
+
await wait(options.pollIntervalMs);
|
|
55
|
+
}
|
|
56
|
+
const activity = await options.getActivity();
|
|
57
|
+
options.onPhase?.("drain_timeout", activity);
|
|
58
|
+
return { ready: false, draining: true, reason: "drain_timeout" };
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=updateCoordinator.js.map
|
|
@@ -54,6 +54,10 @@ export declare function recordUpdateInstalled(version: string, stateFilePath?: s
|
|
|
54
54
|
export declare function abandonPendingUpdate(version: string, stateFilePath?: string): boolean;
|
|
55
55
|
/** Persist a stage-specific updater failure so status remains actionable. */
|
|
56
56
|
export declare function recordUpdateFailure(version: string, stage: NonNullable<UpdateState["lastFailure"]>["stage"], message: string, stateFilePath?: string): void;
|
|
57
|
+
/** Persist why a discovered update is waiting without treating it as a failure. */
|
|
58
|
+
export declare function recordUpdateDeferred(version: string, reason: NonNullable<UpdateState["deferredUpdate"]>["reason"], activeRequests: number | null, stateFilePath?: string): void;
|
|
59
|
+
/** Clear matching updater deferral metadata once work can proceed. */
|
|
60
|
+
export declare function clearUpdateDeferral(version?: string, stateFilePath?: string): boolean;
|
|
57
61
|
/** Complete an updater-managed install after a manual or automatic restart. */
|
|
58
62
|
export declare function reconcileRunningUpdate(runningVersion: string, stateFilePath?: string): boolean;
|
|
59
63
|
/**
|
|
@@ -56,6 +56,26 @@ function isValidLastFailure(value) {
|
|
|
56
56
|
UPDATE_FAILURE_STAGES.has(candidate.stage) &&
|
|
57
57
|
typeof candidate.message === "string");
|
|
58
58
|
}
|
|
59
|
+
function isValidDeferredUpdate(value) {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const candidate = value;
|
|
64
|
+
return (typeof candidate.version === "string" &&
|
|
65
|
+
typeof candidate.since === "string" &&
|
|
66
|
+
typeof candidate.updatedAt === "string" &&
|
|
67
|
+
[
|
|
68
|
+
"waiting_for_quiet",
|
|
69
|
+
"draining",
|
|
70
|
+
"drain_timeout",
|
|
71
|
+
"drain_unavailable",
|
|
72
|
+
"activity_unavailable",
|
|
73
|
+
].includes(String(candidate.reason)) &&
|
|
74
|
+
(candidate.activeRequests === null ||
|
|
75
|
+
(typeof candidate.activeRequests === "number" &&
|
|
76
|
+
Number.isInteger(candidate.activeRequests) &&
|
|
77
|
+
candidate.activeRequests >= 0)));
|
|
78
|
+
}
|
|
59
79
|
// ============================================
|
|
60
80
|
// Exported Functions
|
|
61
81
|
// ============================================
|
|
@@ -70,6 +90,7 @@ export function getDefaultUpdateState() {
|
|
|
70
90
|
lastUpdateAt: null,
|
|
71
91
|
lastUpdateVersion: null,
|
|
72
92
|
pendingRestartVersion: null,
|
|
93
|
+
deferredUpdate: null,
|
|
73
94
|
lastFailure: null,
|
|
74
95
|
};
|
|
75
96
|
}
|
|
@@ -106,6 +127,9 @@ export function loadUpdateState(stateFilePath) {
|
|
|
106
127
|
pendingRestartVersion: typeof candidate.pendingRestartVersion === "string"
|
|
107
128
|
? candidate.pendingRestartVersion
|
|
108
129
|
: null,
|
|
130
|
+
deferredUpdate: isValidDeferredUpdate(candidate.deferredUpdate)
|
|
131
|
+
? candidate.deferredUpdate
|
|
132
|
+
: null,
|
|
109
133
|
lastFailure: isValidLastFailure(candidate.lastFailure)
|
|
110
134
|
? candidate.lastFailure
|
|
111
135
|
: null,
|
|
@@ -185,6 +209,7 @@ export function recordSuccessfulUpdate(version, stateFilePath) {
|
|
|
185
209
|
state.lastUpdateAt = new Date().toISOString();
|
|
186
210
|
state.lastUpdateVersion = version;
|
|
187
211
|
state.pendingRestartVersion = null;
|
|
212
|
+
state.deferredUpdate = null;
|
|
188
213
|
state.lastFailure = null;
|
|
189
214
|
delete state.suppressedVersions[version];
|
|
190
215
|
saveUpdateState(state, stateFilePath);
|
|
@@ -217,6 +242,32 @@ export function recordUpdateFailure(version, stage, message, stateFilePath) {
|
|
|
217
242
|
};
|
|
218
243
|
saveUpdateState(state, stateFilePath);
|
|
219
244
|
}
|
|
245
|
+
/** Persist why a discovered update is waiting without treating it as a failure. */
|
|
246
|
+
export function recordUpdateDeferred(version, reason, activeRequests, stateFilePath) {
|
|
247
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
state.deferredUpdate = {
|
|
250
|
+
version,
|
|
251
|
+
since: state.deferredUpdate?.version === version
|
|
252
|
+
? state.deferredUpdate.since
|
|
253
|
+
: now,
|
|
254
|
+
updatedAt: now,
|
|
255
|
+
reason,
|
|
256
|
+
activeRequests,
|
|
257
|
+
};
|
|
258
|
+
saveUpdateState(state, stateFilePath);
|
|
259
|
+
}
|
|
260
|
+
/** Clear matching updater deferral metadata once work can proceed. */
|
|
261
|
+
export function clearUpdateDeferral(version, stateFilePath) {
|
|
262
|
+
const state = loadUpdateState(stateFilePath);
|
|
263
|
+
if (!state?.deferredUpdate ||
|
|
264
|
+
(version !== undefined && state.deferredUpdate.version !== version)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
state.deferredUpdate = null;
|
|
268
|
+
saveUpdateState(state, stateFilePath);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
220
271
|
/** Complete an updater-managed install after a manual or automatic restart. */
|
|
221
272
|
export function reconcileRunningUpdate(runningVersion, stateFilePath) {
|
|
222
273
|
const state = loadUpdateState(stateFilePath);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, statSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
/** Open a restrictive worker log without making proxy startup depend on it. */
|
|
5
|
+
export function openProxyWorkerLog(filename, logDir = join(homedir(), ".neurolink", "logs")) {
|
|
6
|
+
let fd;
|
|
7
|
+
try {
|
|
8
|
+
if (basename(filename) !== filename) {
|
|
9
|
+
throw new Error("worker log filename must not contain a path");
|
|
10
|
+
}
|
|
11
|
+
if (!existsSync(logDir)) {
|
|
12
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
14
|
+
else if (!statSync(logDir).isDirectory()) {
|
|
15
|
+
throw new Error("worker log path exists but is not a directory");
|
|
16
|
+
}
|
|
17
|
+
chmodSync(logDir, 0o700);
|
|
18
|
+
const path = join(logDir, filename);
|
|
19
|
+
fd = openSync(path, "a", 0o600);
|
|
20
|
+
chmodSync(path, 0o600);
|
|
21
|
+
return {
|
|
22
|
+
stdio: fd,
|
|
23
|
+
close: () => closeSync(fd),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (fd !== undefined) {
|
|
28
|
+
try {
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// The log is optional and startup must remain fail-open.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
stdio: "ignore",
|
|
37
|
+
close: () => undefined,
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=workerLog.js.map
|
|
@@ -143,6 +143,7 @@ declare function handleAnthropicStreamingSuccessResponse(args: {
|
|
|
143
143
|
attemptNumber: number;
|
|
144
144
|
finalBodyStr: string;
|
|
145
145
|
upstreamSpan?: import("@opentelemetry/api").Span;
|
|
146
|
+
logAttempt: AnthropicAttemptLogger;
|
|
146
147
|
logProxyBody: ProxyBodyCaptureLogger;
|
|
147
148
|
logFinalRequest: (status: number, accountLabel: string, accountType: string, errorType?: string, errorMessage?: string, extra?: {
|
|
148
149
|
inputTokens?: number;
|
|
@@ -25,7 +25,7 @@ import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js";
|
|
|
25
25
|
import { relocateClientSystemIntoMessages } from "../../proxy/systemRelocation.js";
|
|
26
26
|
import { logBodyCapture, logRequest, logRequestAttempt, } from "../../proxy/requestLogger.js";
|
|
27
27
|
import { createSSEInterceptor } from "../../proxy/sseInterceptor.js";
|
|
28
|
-
import { createStreamTerminalOutcomeTracker, mergeStreamTerminalOutcome, } from "../../proxy/streamOutcome.js";
|
|
28
|
+
import { createStreamTerminalOutcomeTracker, mergeStreamTerminalOutcome, preflightAnthropicStream, } from "../../proxy/streamOutcome.js";
|
|
29
29
|
import { isPermanentRefreshFailure, needsRefresh, persistTokens, refreshToken, } from "../../proxy/tokenRefresh.js";
|
|
30
30
|
import { buildProxyTranslationPlan, parseRetryAfterMs, } from "../../proxy/routingPolicy.js";
|
|
31
31
|
import { writeJsonSnapshotAtomically } from "../../proxy/snapshotPersistence.js";
|
|
@@ -2182,7 +2182,7 @@ function buildClaudeAnthropicFailureResponse(args) {
|
|
|
2182
2182
|
});
|
|
2183
2183
|
}
|
|
2184
2184
|
async function handleAnthropicSuccessfulResponse(args) {
|
|
2185
|
-
const { ctx, body, account, accountState, response, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
|
|
2185
|
+
const { ctx, body, account, accountState, response, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
|
|
2186
2186
|
accountState.consecutiveRefreshFailures = 0;
|
|
2187
2187
|
logger.always(`[proxy] ← ${response.status} account=${account.label}`);
|
|
2188
2188
|
const quota = parseQuotaHeaders(response.headers);
|
|
@@ -2222,6 +2222,7 @@ async function handleAnthropicSuccessfulResponse(args) {
|
|
|
2222
2222
|
attemptNumber,
|
|
2223
2223
|
finalBodyStr,
|
|
2224
2224
|
upstreamSpan,
|
|
2225
|
+
logAttempt,
|
|
2225
2226
|
logProxyBody,
|
|
2226
2227
|
logFinalRequest,
|
|
2227
2228
|
});
|
|
@@ -2241,7 +2242,7 @@ async function handleAnthropicSuccessfulResponse(args) {
|
|
|
2241
2242
|
});
|
|
2242
2243
|
}
|
|
2243
2244
|
async function handleAnthropicStreamingSuccessResponse(args) {
|
|
2244
|
-
const { account, accountState
|
|
2245
|
+
const { account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
|
|
2245
2246
|
if (!response.body) {
|
|
2246
2247
|
upstreamSpan?.end();
|
|
2247
2248
|
tracer?.setError("stream_error", "No response body from upstream");
|
|
@@ -2265,44 +2266,129 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
2265
2266
|
return { response: clientError };
|
|
2266
2267
|
}
|
|
2267
2268
|
const reader = response.body.getReader();
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
catch (error) {
|
|
2273
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2269
|
+
const preflight = await preflightAnthropicStream(reader);
|
|
2270
|
+
if (preflight.kind === "transport_error") {
|
|
2271
|
+
const message = describeTransportError(preflight.error);
|
|
2272
|
+
const partialBody = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
|
2274
2273
|
logger.always(`[proxy] stream failed before first chunk account=${account.label}: ${message}; trying next account`);
|
|
2275
2274
|
recordAttemptError(account.label, account.type, 502);
|
|
2275
|
+
logAttempt(502, "stream_error", message, { retryable: true });
|
|
2276
2276
|
tracer?.recordRetry(account.label, "stream_before_first_chunk");
|
|
2277
2277
|
upstreamSpan?.end();
|
|
2278
2278
|
logProxyBody({
|
|
2279
2279
|
phase: "upstream_response",
|
|
2280
2280
|
headers: responseHeaders,
|
|
2281
|
-
body:
|
|
2282
|
-
bodySize: Buffer.byteLength(
|
|
2281
|
+
body: partialBody,
|
|
2282
|
+
bodySize: Buffer.byteLength(partialBody, "utf8"),
|
|
2283
2283
|
contentType: responseHeaders["content-type"] ?? "text/event-stream",
|
|
2284
2284
|
account: account.label,
|
|
2285
2285
|
accountType: account.type,
|
|
2286
2286
|
attempt: attemptNumber,
|
|
2287
|
-
responseStatus:
|
|
2287
|
+
responseStatus: response.status,
|
|
2288
2288
|
durationMs: Date.now() - fetchStartMs,
|
|
2289
|
+
metadata: { logicalStatus: 502, transportError: message },
|
|
2289
2290
|
});
|
|
2290
|
-
return {
|
|
2291
|
+
return {
|
|
2292
|
+
retryNextAccount: true,
|
|
2293
|
+
failure: { message, rateLimit: false },
|
|
2294
|
+
};
|
|
2291
2295
|
}
|
|
2292
|
-
if (
|
|
2296
|
+
if (preflight.kind === "empty") {
|
|
2293
2297
|
await reader.cancel().catch(() => {
|
|
2294
2298
|
// Best-effort release before rotating to another account.
|
|
2295
2299
|
});
|
|
2296
2300
|
logger.always(`[proxy] ← empty stream from account=${account.label}, trying next`);
|
|
2301
|
+
recordAttemptError(account.label, account.type, 502);
|
|
2302
|
+
logAttempt(502, "empty_stream", "Empty upstream stream", {
|
|
2303
|
+
retryable: true,
|
|
2304
|
+
});
|
|
2297
2305
|
tracer?.recordRetry(account.label, "empty_stream");
|
|
2298
2306
|
upstreamSpan?.end();
|
|
2299
|
-
|
|
2307
|
+
logProxyBody({
|
|
2308
|
+
phase: "upstream_response",
|
|
2309
|
+
headers: responseHeaders,
|
|
2310
|
+
body: "",
|
|
2311
|
+
bodySize: 0,
|
|
2312
|
+
contentType: responseHeaders["content-type"] ?? "text/event-stream",
|
|
2313
|
+
account: account.label,
|
|
2314
|
+
accountType: account.type,
|
|
2315
|
+
attempt: attemptNumber,
|
|
2316
|
+
responseStatus: response.status,
|
|
2317
|
+
durationMs: Date.now() - fetchStartMs,
|
|
2318
|
+
metadata: { logicalStatus: 502, upstreamErrorType: "empty_stream" },
|
|
2319
|
+
});
|
|
2320
|
+
return {
|
|
2321
|
+
retryNextAccount: true,
|
|
2322
|
+
failure: { message: "Empty upstream stream", rateLimit: false },
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
if (preflight.kind === "sse_error") {
|
|
2326
|
+
await reader.cancel().catch(() => {
|
|
2327
|
+
// Best-effort release before rotating to another account.
|
|
2328
|
+
});
|
|
2329
|
+
const bodyText = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
|
2330
|
+
const isRateLimit = preflight.errorType === "rate_limit_error";
|
|
2331
|
+
const logicalStatus = isRateLimit ? 429 : 502;
|
|
2332
|
+
const quota = parseQuotaHeaders(responseHeaders);
|
|
2333
|
+
const now = Date.now();
|
|
2334
|
+
if (isRateLimit) {
|
|
2335
|
+
const cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders));
|
|
2336
|
+
accountState.quota = quota ?? accountState.quota;
|
|
2337
|
+
const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
|
|
2338
|
+
if (!accountState.coolingUntil ||
|
|
2339
|
+
cooldownPlan.coolingUntil > accountState.coolingUntil) {
|
|
2340
|
+
accountState.coolingUntil = cooldownPlan.coolingUntil;
|
|
2341
|
+
accountState.coolingReason = cooldownPlan.reason;
|
|
2342
|
+
await saveAccountCooldown(account.key, cooldownPlan.coolingUntil, cooldownPlan.reason).catch(() => {
|
|
2343
|
+
// Non-fatal: routing already has the in-memory cooldown.
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
recordAttemptError(account.label, account.type, 429, rateLimitKind);
|
|
2347
|
+
logAttempt(429, "rate_limit_error", preflight.message, {
|
|
2348
|
+
retryable: true,
|
|
2349
|
+
rateLimitKind,
|
|
2350
|
+
cooldownReason: cooldownPlan.reason,
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
else {
|
|
2354
|
+
recordAttemptError(account.label, account.type, logicalStatus);
|
|
2355
|
+
logAttempt(logicalStatus, preflight.errorType, preflight.message, {
|
|
2356
|
+
retryable: true,
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
logger.always(`[proxy] immediate SSE ${preflight.errorType} account=${account.label}: ${preflight.message}; rotating before client commit`);
|
|
2360
|
+
tracer?.recordRetry(account.label, isRateLimit
|
|
2361
|
+
? "stream_rate_limit_before_commit"
|
|
2362
|
+
: "stream_error_before_commit");
|
|
2363
|
+
upstreamSpan?.end();
|
|
2364
|
+
logProxyBody({
|
|
2365
|
+
phase: "upstream_response",
|
|
2366
|
+
headers: responseHeaders,
|
|
2367
|
+
body: bodyText,
|
|
2368
|
+
bodySize: Buffer.byteLength(bodyText, "utf8"),
|
|
2369
|
+
contentType: responseHeaders["content-type"] ?? "text/event-stream",
|
|
2370
|
+
account: account.label,
|
|
2371
|
+
accountType: account.type,
|
|
2372
|
+
attempt: attemptNumber,
|
|
2373
|
+
responseStatus: response.status,
|
|
2374
|
+
durationMs: Date.now() - fetchStartMs,
|
|
2375
|
+
metadata: {
|
|
2376
|
+
logicalStatus,
|
|
2377
|
+
upstreamErrorType: preflight.errorType,
|
|
2378
|
+
},
|
|
2379
|
+
});
|
|
2380
|
+
return {
|
|
2381
|
+
retryNextAccount: true,
|
|
2382
|
+
failure: { message: preflight.message, rateLimit: isRateLimit },
|
|
2383
|
+
};
|
|
2300
2384
|
}
|
|
2301
2385
|
const streamOutcomeTracker = createStreamTerminalOutcomeTracker();
|
|
2302
2386
|
let mainStreamClosed = false;
|
|
2303
2387
|
const remainingStream = new ReadableStream({
|
|
2304
2388
|
start(controller) {
|
|
2305
|
-
|
|
2389
|
+
for (const chunk of preflight.chunks) {
|
|
2390
|
+
controller.enqueue(chunk);
|
|
2391
|
+
}
|
|
2306
2392
|
},
|
|
2307
2393
|
async pull(controller) {
|
|
2308
2394
|
if (mainStreamClosed) {
|
|
@@ -2865,10 +2951,12 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
2865
2951
|
await clearAuthCooldownAfterRefresh(account, accountState);
|
|
2866
2952
|
headers.authorization = `Bearer ${account.token}`;
|
|
2867
2953
|
const retryAttemptNumber = allocateAttemptNumber();
|
|
2954
|
+
const retryAttemptStartedAt = Date.now();
|
|
2868
2955
|
recordAttempt(account.label, account.type);
|
|
2869
2956
|
const retryLogAttempt = (status, errorType, errorMessage, extra) => logAttempt(status, errorType, errorMessage, {
|
|
2870
2957
|
...extra,
|
|
2871
2958
|
attempt: retryAttemptNumber,
|
|
2959
|
+
attemptDurationMs: Date.now() - retryAttemptStartedAt,
|
|
2872
2960
|
});
|
|
2873
2961
|
const retryBodyStr = buildUpstreamBody(account.token).bodyStr;
|
|
2874
2962
|
const retryFetchStartMs = Date.now();
|
|
@@ -3423,7 +3511,9 @@ function createClaudeRequestRuntimeContext(args) {
|
|
|
3423
3511
|
}
|
|
3424
3512
|
function createAnthropicAttemptLogger(args) {
|
|
3425
3513
|
const { ctx, body, toolCount, requestStart, tracer, account, attemptNumber } = args;
|
|
3514
|
+
const attemptStartedAt = Date.now();
|
|
3426
3515
|
return (status, errorType, errorMessage, extra) => {
|
|
3516
|
+
const attemptCompletedAt = Date.now();
|
|
3427
3517
|
const traceCtx = tracer?.getTraceContext();
|
|
3428
3518
|
logRequestAttempt({
|
|
3429
3519
|
timestamp: new Date().toISOString(),
|
|
@@ -3437,7 +3527,8 @@ function createAnthropicAttemptLogger(args) {
|
|
|
3437
3527
|
account: account.label,
|
|
3438
3528
|
accountType: account.type,
|
|
3439
3529
|
responseStatus: status,
|
|
3440
|
-
responseTimeMs:
|
|
3530
|
+
responseTimeMs: attemptCompletedAt - requestStart,
|
|
3531
|
+
attemptDurationMs: extra?.attemptDurationMs ?? attemptCompletedAt - attemptStartedAt,
|
|
3441
3532
|
...(errorType ? { errorType } : {}),
|
|
3442
3533
|
...(errorMessage ? { errorMessage } : {}),
|
|
3443
3534
|
...(extra?.errorCode ? { errorCode: extra.errorCode } : {}),
|
|
@@ -4095,10 +4186,16 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
4095
4186
|
attemptNumber: loopState.attemptNumber,
|
|
4096
4187
|
finalBodyStr: preparedAttempt.finalBodyStr,
|
|
4097
4188
|
upstreamSpan,
|
|
4189
|
+
logAttempt,
|
|
4098
4190
|
logProxyBody,
|
|
4099
4191
|
logFinalRequest,
|
|
4100
4192
|
});
|
|
4101
4193
|
if ("retryNextAccount" in successResult) {
|
|
4194
|
+
if (successResult.failure) {
|
|
4195
|
+
loopState.lastError = successResult.failure.message;
|
|
4196
|
+
loopState.sawRateLimit ||= successResult.failure.rateLimit;
|
|
4197
|
+
loopState.sawTransientFailure ||= !successResult.failure.rateLimit;
|
|
4198
|
+
}
|
|
4102
4199
|
continue accountLoop;
|
|
4103
4200
|
}
|
|
4104
4201
|
return successResult.response;
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* provider/model pairs (e.g. "gpt-4o" -> vertex/gemini-2.5-pro).
|
|
13
13
|
*/
|
|
14
14
|
import type { ModelRouterInterface, ProxyRuntimeConfigProvider, RouteGroup } from "../../types/index.js";
|
|
15
|
+
declare function resolveStreamCancellationLifecycle(terminalStreamError: string | undefined): {
|
|
16
|
+
status: number;
|
|
17
|
+
errorType: string;
|
|
18
|
+
errorMessage: string;
|
|
19
|
+
};
|
|
15
20
|
/**
|
|
16
21
|
* Create OpenAI-compatible proxy routes.
|
|
17
22
|
*
|
|
@@ -27,3 +32,7 @@ import type { ModelRouterInterface, ProxyRuntimeConfigProvider, RouteGroup } fro
|
|
|
27
32
|
* @returns RouteGroup with OpenAI-compatible endpoints.
|
|
28
33
|
*/
|
|
29
34
|
export declare function createOpenAIProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, loopbackPort?: number, runtimeConfigProvider?: ProxyRuntimeConfigProvider): RouteGroup;
|
|
35
|
+
export declare const __testHooks: {
|
|
36
|
+
resolveStreamCancellationLifecycle: typeof resolveStreamCancellationLifecycle;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -27,6 +27,19 @@ const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — long enough for slow
|
|
|
27
27
|
// `createOpenAIProxyRoutes`'s third argument when the actual listener port is
|
|
28
28
|
// known (e.g. when started from the CLI handler).
|
|
29
29
|
const DEFAULT_LOOPBACK_PORT = 55669;
|
|
30
|
+
function resolveStreamCancellationLifecycle(terminalStreamError) {
|
|
31
|
+
return terminalStreamError
|
|
32
|
+
? {
|
|
33
|
+
status: 502,
|
|
34
|
+
errorType: "loopback_stream_error",
|
|
35
|
+
errorMessage: terminalStreamError,
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
status: 499,
|
|
39
|
+
errorType: "client_cancelled",
|
|
40
|
+
errorMessage: "Client cancelled the OpenAI-compatible stream",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
30
43
|
/**
|
|
31
44
|
* Build an OpenAI-shaped error as a typed Response with the intended status.
|
|
32
45
|
*
|
|
@@ -188,7 +201,8 @@ async function handleOpenAIToAnthropicBridge(args) {
|
|
|
188
201
|
await reader.cancel(reason);
|
|
189
202
|
}
|
|
190
203
|
finally {
|
|
191
|
-
|
|
204
|
+
const cancellation = resolveStreamCancellationLifecycle(terminalStreamError);
|
|
205
|
+
await finishLifecycle(cancellation.status, cancellation.errorType, cancellation.errorMessage);
|
|
192
206
|
}
|
|
193
207
|
},
|
|
194
208
|
});
|
|
@@ -381,4 +395,5 @@ export function createOpenAIProxyRoutes(modelRouter, basePath = "", loopbackPort
|
|
|
381
395
|
],
|
|
382
396
|
};
|
|
383
397
|
}
|
|
398
|
+
export const __testHooks = { resolveStreamCancellationLifecycle };
|
|
384
399
|
//# sourceMappingURL=openaiProxyRoutes.js.map
|
package/dist/lib/types/cli.d.ts
CHANGED
|
@@ -791,6 +791,13 @@ export type ProxyStatusArgs = {
|
|
|
791
791
|
format?: "text" | "json";
|
|
792
792
|
quiet?: boolean;
|
|
793
793
|
};
|
|
794
|
+
/** Arguments accepted by `neurolink proxy analyze` */
|
|
795
|
+
export type ProxyAnalyzeArgs = {
|
|
796
|
+
logsDir?: string;
|
|
797
|
+
since?: string;
|
|
798
|
+
format?: "text" | "json";
|
|
799
|
+
quiet?: boolean;
|
|
800
|
+
};
|
|
794
801
|
/** Arguments accepted by hidden `neurolink proxy guard` command */
|
|
795
802
|
export type ProxyGuardArgs = {
|
|
796
803
|
host?: string;
|