@juspay/neurolink 9.92.3 → 9.93.1
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 +5 -3
- package/dist/cli/commands/proxy.js +360 -69
- 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/lib/proxy/proxyActivity.d.ts +2 -2
- package/dist/lib/proxy/proxyActivity.js +49 -9
- 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/proxyLifecycle.d.ts +8 -0
- package/dist/lib/proxy/proxyLifecycle.js +333 -0
- package/dist/lib/proxy/requestLogger.js +6 -0
- package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
- package/dist/lib/proxy/sseInterceptor.js +6 -5
- 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/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/lib/server/routes/claudeProxyRoutes.js +137 -35
- package/dist/lib/types/cli.d.ts +7 -0
- package/dist/lib/types/proxy.d.ts +269 -0
- package/dist/proxy/proxyActivity.d.ts +2 -2
- package/dist/proxy/proxyActivity.js +49 -9
- 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/proxyLifecycle.d.ts +8 -0
- package/dist/proxy/proxyLifecycle.js +332 -0
- package/dist/proxy/requestLogger.js +6 -0
- package/dist/proxy/sseInterceptor.d.ts +9 -1
- package/dist/proxy/sseInterceptor.js +6 -5
- 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/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/server/routes/claudeProxyRoutes.js +137 -35
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/proxy.d.ts +269 -0
- package/package.json +4 -1
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* (generate/stream), with an optional ModelRouter for model remapping.
|
|
11
11
|
*/
|
|
12
12
|
import type { CommandModule } from "yargs";
|
|
13
|
-
import type {
|
|
13
|
+
import type { Hono } from "hono";
|
|
14
|
+
import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
|
|
14
15
|
import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
|
|
15
16
|
export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
|
|
16
17
|
export declare function createProxyStartApp(params: {
|
|
@@ -24,9 +25,10 @@ export declare function createProxyStartApp(params: {
|
|
|
24
25
|
primaryAccountKey: string | undefined;
|
|
25
26
|
accountAllowlist: AccountAllowlist | undefined;
|
|
26
27
|
runtimeConfigStore?: ProxyRuntimeConfigStore;
|
|
28
|
+
updateControlToken?: string;
|
|
27
29
|
}): Promise<{
|
|
28
|
-
app:
|
|
29
|
-
readiness:
|
|
30
|
+
app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
31
|
+
readiness: ProxyReadinessState;
|
|
30
32
|
}>;
|
|
31
33
|
export declare const proxyStartCommand: CommandModule<object, ProxyStartArgs>;
|
|
32
34
|
export declare const proxyStatusCommand: CommandModule<object, ProxyStatusArgs>;
|
|
@@ -14,16 +14,19 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import ora from "ora";
|
|
17
|
-
import { buildProxyHealthResponse, createProxyReadinessState, markProxyReady, waitForProxyReadiness, } from "../../lib/proxy/proxyHealth.js";
|
|
17
|
+
import { buildProxyHealthResponse, createProxyReadinessState, markProxyDrainingForUpdate, markProxyReady, resumeProxyConnections, waitForProxyReadiness, } from "../../lib/proxy/proxyHealth.js";
|
|
18
18
|
import { logger } from "../../lib/utils/logger.js";
|
|
19
|
+
import { withTimeout } from "../../lib/utils/async/withTimeout.js";
|
|
19
20
|
import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
|
|
20
21
|
import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
|
|
21
22
|
import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
|
|
22
23
|
import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
|
|
23
24
|
import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../lib/proxy/proxyActivity.js";
|
|
25
|
+
import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../lib/proxy/proxyLifecycle.js";
|
|
24
26
|
import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, validateInstalledVersion, } from "../../lib/proxy/globalInstaller.js";
|
|
25
27
|
import { startUpdaterWorkerSupervisor } from "../../lib/proxy/updaterSupervisor.js";
|
|
26
|
-
import {
|
|
28
|
+
import { waitForProxyUpdateWindow } from "../../lib/proxy/updateCoordinator.js";
|
|
29
|
+
import { abandonPendingUpdate, clearUpdateDeferral, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateDeferred, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
|
|
27
30
|
import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
|
|
28
31
|
import { createRequire } from "node:module";
|
|
29
32
|
import { fileURLToPath } from "node:url";
|
|
@@ -31,6 +34,9 @@ import packageJson from "../../../package.json" with { type: "json" };
|
|
|
31
34
|
const _require = createRequire(import.meta.url);
|
|
32
35
|
const PROXY_VERSION = packageJson.version;
|
|
33
36
|
const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
|
|
37
|
+
const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
38
|
+
const PROXY_UPDATE_CONTROL_TOKEN = process.env.NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN?.trim() ||
|
|
39
|
+
crypto.randomUUID();
|
|
34
40
|
// =============================================================================
|
|
35
41
|
// STATE MANAGEMENT
|
|
36
42
|
// =============================================================================
|
|
@@ -367,16 +373,52 @@ async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
|
|
|
367
373
|
return null;
|
|
368
374
|
}
|
|
369
375
|
}
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
376
|
+
async function setProxyUpdateDrain(host, port, draining) {
|
|
377
|
+
const confirmState = async () => {
|
|
378
|
+
try {
|
|
379
|
+
const response = await fetch(`http://${host}:${port}/status`, {
|
|
380
|
+
signal: AbortSignal.timeout(3_000),
|
|
381
|
+
});
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const payload = (await response.json());
|
|
386
|
+
return payload.health?.drainingForUpdate === draining;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
const response = await fetch(`http://${host}:${port}/internal/update-control`, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
"content-type": "application/json",
|
|
397
|
+
"x-neurolink-update-token": PROXY_UPDATE_CONTROL_TOKEN,
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({ action: draining ? "drain" : "resume" }),
|
|
400
|
+
signal: AbortSignal.timeout(5_000),
|
|
401
|
+
});
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
return confirmState();
|
|
404
|
+
}
|
|
405
|
+
const payload = (await response.json());
|
|
406
|
+
return payload.draining === draining;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return confirmState();
|
|
373
410
|
}
|
|
374
|
-
|
|
375
|
-
|
|
411
|
+
}
|
|
412
|
+
async function resumeProxyUpdateDrain(host, port, attempts = 3) {
|
|
413
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
414
|
+
if (await setProxyUpdateDrain(host, port, false)) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
if (attempt < attempts) {
|
|
418
|
+
await sleep(1_000);
|
|
419
|
+
}
|
|
376
420
|
}
|
|
377
|
-
|
|
378
|
-
return (Number.isFinite(lastActivityMs) &&
|
|
379
|
-
nowMs - lastActivityMs >= quietThresholdMs);
|
|
421
|
+
return false;
|
|
380
422
|
}
|
|
381
423
|
// ---------------------------------------------------------------------------
|
|
382
424
|
// Stable entrypoint for launchd
|
|
@@ -553,7 +595,10 @@ function spawnProxyUpdater(host, port, parentPid) {
|
|
|
553
595
|
const child = spawn(process.execPath, args, {
|
|
554
596
|
detached: true,
|
|
555
597
|
stdio: ["ignore", logFd, logFd],
|
|
556
|
-
env:
|
|
598
|
+
env: {
|
|
599
|
+
...process.env,
|
|
600
|
+
NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN: PROXY_UPDATE_CONTROL_TOKEN,
|
|
601
|
+
},
|
|
557
602
|
});
|
|
558
603
|
child.once("error", (error) => {
|
|
559
604
|
logger.always(`[proxy] updater worker error pid=${child.pid ?? "unknown"}: ${error.message}`);
|
|
@@ -643,6 +688,20 @@ export function mapClaudeErrorTypeToStatus(errorType) {
|
|
|
643
688
|
return 502;
|
|
644
689
|
}
|
|
645
690
|
}
|
|
691
|
+
function getProxyRuntimeErrorCode(error) {
|
|
692
|
+
let current = error;
|
|
693
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
694
|
+
if (!current || typeof current !== "object") {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
const code = current.code;
|
|
698
|
+
if (typeof code === "string") {
|
|
699
|
+
return code;
|
|
700
|
+
}
|
|
701
|
+
current = current.cause;
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
646
705
|
async function ensureProxyStartAllowed(spinner) {
|
|
647
706
|
const ignoreLaunchd = process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "1" ||
|
|
648
707
|
process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "true";
|
|
@@ -710,6 +769,128 @@ async function createProxyNeurolinkRuntime(logsDir) {
|
|
|
710
769
|
cleanupLogs(7, 500);
|
|
711
770
|
return { neurolink, cleanupLogs };
|
|
712
771
|
}
|
|
772
|
+
function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
773
|
+
app.use("/v1/*", async (c, next) => {
|
|
774
|
+
const startedMonotonicMs = performance.now();
|
|
775
|
+
const contentLengthHeader = c.req.raw.headers.get("content-length");
|
|
776
|
+
const rawContentLength = contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader);
|
|
777
|
+
const requestBytes = Number.isFinite(rawContentLength) && rawContentLength >= 0
|
|
778
|
+
? rawContentLength
|
|
779
|
+
: undefined;
|
|
780
|
+
const sessionId = c.req.raw.headers.get("x-neurolink-session-id") ??
|
|
781
|
+
c.req.raw.headers.get("x-claude-code-session-id") ??
|
|
782
|
+
undefined;
|
|
783
|
+
const sessionHash = hashProxyLifecycleSessionId(sessionId);
|
|
784
|
+
const metadata = {
|
|
785
|
+
requestId: crypto.randomUUID(),
|
|
786
|
+
method: c.req.method,
|
|
787
|
+
path: c.req.path,
|
|
788
|
+
startedAt: Date.now(),
|
|
789
|
+
model: "-",
|
|
790
|
+
stream: false,
|
|
791
|
+
toolCount: 0,
|
|
792
|
+
rejectForUpdate: readiness.drainingForUpdate,
|
|
793
|
+
};
|
|
794
|
+
requestMetadata.set(c.req.raw, metadata);
|
|
795
|
+
const finishActivity = metadata.rejectForUpdate
|
|
796
|
+
? () => undefined
|
|
797
|
+
: beginProxyRequest();
|
|
798
|
+
const finish = () => {
|
|
799
|
+
finishActivity();
|
|
800
|
+
requestMetadata.delete(c.req.raw);
|
|
801
|
+
};
|
|
802
|
+
// The route adapter populates model/stream/toolCount after parsing. Omit
|
|
803
|
+
// them at acceptance instead of publishing misleading placeholder values;
|
|
804
|
+
// subsequent events carry the parsed metadata under the same request ID.
|
|
805
|
+
logProxyLifecycleEvent({
|
|
806
|
+
event: "request_accepted",
|
|
807
|
+
requestId: metadata.requestId,
|
|
808
|
+
method: metadata.method,
|
|
809
|
+
path: metadata.path,
|
|
810
|
+
sessionHash,
|
|
811
|
+
requestBytes,
|
|
812
|
+
elapsedMs: 0,
|
|
813
|
+
monotonicMs: startedMonotonicMs,
|
|
814
|
+
});
|
|
815
|
+
try {
|
|
816
|
+
await next();
|
|
817
|
+
const responseStatus = c.res.status;
|
|
818
|
+
logProxyLifecycleEvent({
|
|
819
|
+
event: "response_headers",
|
|
820
|
+
requestId: metadata.requestId,
|
|
821
|
+
method: metadata.method,
|
|
822
|
+
path: metadata.path,
|
|
823
|
+
model: metadata.model,
|
|
824
|
+
stream: metadata.stream,
|
|
825
|
+
toolCount: metadata.toolCount,
|
|
826
|
+
sessionHash,
|
|
827
|
+
requestBytes,
|
|
828
|
+
responseStatus,
|
|
829
|
+
elapsedMs: performance.now() - startedMonotonicMs,
|
|
830
|
+
});
|
|
831
|
+
c.res = trackProxyResponse(c.res, finish, {
|
|
832
|
+
onFirstChunk: ({ observedBodyBytes, responseChunks }) => {
|
|
833
|
+
logProxyLifecycleEvent({
|
|
834
|
+
event: "response_first_chunk",
|
|
835
|
+
requestId: metadata.requestId,
|
|
836
|
+
method: metadata.method,
|
|
837
|
+
path: metadata.path,
|
|
838
|
+
model: metadata.model,
|
|
839
|
+
stream: metadata.stream,
|
|
840
|
+
toolCount: metadata.toolCount,
|
|
841
|
+
sessionHash,
|
|
842
|
+
requestBytes,
|
|
843
|
+
responseStatus,
|
|
844
|
+
observedBodyBytes,
|
|
845
|
+
responseChunks,
|
|
846
|
+
elapsedMs: performance.now() - startedMonotonicMs,
|
|
847
|
+
});
|
|
848
|
+
},
|
|
849
|
+
onTerminal: ({ outcome, observedBodyBytes, responseChunks }) => {
|
|
850
|
+
logProxyLifecycleEvent({
|
|
851
|
+
event: "request_terminal",
|
|
852
|
+
requestId: metadata.requestId,
|
|
853
|
+
method: metadata.method,
|
|
854
|
+
path: metadata.path,
|
|
855
|
+
model: metadata.model,
|
|
856
|
+
stream: metadata.stream,
|
|
857
|
+
toolCount: metadata.toolCount,
|
|
858
|
+
sessionHash,
|
|
859
|
+
requestBytes,
|
|
860
|
+
responseStatus,
|
|
861
|
+
observedBodyBytes,
|
|
862
|
+
responseChunks,
|
|
863
|
+
elapsedMs: performance.now() - startedMonotonicMs,
|
|
864
|
+
terminalOutcome: outcome,
|
|
865
|
+
errorType: metadata.terminalErrorType,
|
|
866
|
+
errorCode: metadata.terminalErrorCode,
|
|
867
|
+
});
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
// Keep metadata available to app.onError, which records the client-facing
|
|
873
|
+
// failure with the same request ID before deleting the WeakMap entry.
|
|
874
|
+
finishActivity();
|
|
875
|
+
logProxyLifecycleEvent({
|
|
876
|
+
event: "request_terminal",
|
|
877
|
+
requestId: metadata.requestId,
|
|
878
|
+
method: metadata.method,
|
|
879
|
+
path: metadata.path,
|
|
880
|
+
model: metadata.model,
|
|
881
|
+
stream: metadata.stream,
|
|
882
|
+
toolCount: metadata.toolCount,
|
|
883
|
+
sessionHash,
|
|
884
|
+
requestBytes,
|
|
885
|
+
elapsedMs: performance.now() - startedMonotonicMs,
|
|
886
|
+
terminalOutcome: "handler_error",
|
|
887
|
+
errorType: error instanceof Error ? error.name : "unknown_error",
|
|
888
|
+
errorCode: getProxyRuntimeErrorCode(error),
|
|
889
|
+
});
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
713
894
|
export async function createProxyStartApp(params) {
|
|
714
895
|
const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
|
|
715
896
|
const { createOpenAIProxyRoutes } = await import("../../lib/server/routes/openaiProxyRoutes.js");
|
|
@@ -719,7 +900,9 @@ export async function createProxyStartApp(params) {
|
|
|
719
900
|
const app = new Hono();
|
|
720
901
|
const readiness = createProxyReadinessState();
|
|
721
902
|
const requestMetadata = new WeakMap();
|
|
722
|
-
const recordRuntimeError = async (metadata, status, errorType, errorMessage,
|
|
903
|
+
const recordRuntimeError = async (metadata, status, errorType, errorMessage, options) => {
|
|
904
|
+
const clientMessage = options?.clientMessage ?? errorMessage;
|
|
905
|
+
const clientErrorType = options?.clientErrorType ?? errorType;
|
|
723
906
|
recordFinalError(status);
|
|
724
907
|
await Promise.all([
|
|
725
908
|
logRequest({
|
|
@@ -736,6 +919,7 @@ export async function createProxyStartApp(params) {
|
|
|
736
919
|
responseTimeMs: Date.now() - metadata.startedAt,
|
|
737
920
|
errorType,
|
|
738
921
|
errorMessage,
|
|
922
|
+
...(options?.errorCode ? { errorCode: options.errorCode } : {}),
|
|
739
923
|
}),
|
|
740
924
|
logBodyCapture({
|
|
741
925
|
timestamp: new Date().toISOString(),
|
|
@@ -769,7 +953,13 @@ export async function createProxyStartApp(params) {
|
|
|
769
953
|
stream: false,
|
|
770
954
|
toolCount: 0,
|
|
771
955
|
};
|
|
772
|
-
|
|
956
|
+
metadata.terminalErrorType = "unhandled_proxy_error";
|
|
957
|
+
metadata.terminalErrorCode = getProxyRuntimeErrorCode(err);
|
|
958
|
+
await recordRuntimeError(metadata, 502, "unhandled_proxy_error", errMsg, {
|
|
959
|
+
clientMessage: "Proxy internal error",
|
|
960
|
+
clientErrorType: "api_error",
|
|
961
|
+
errorCode: metadata.terminalErrorCode,
|
|
962
|
+
});
|
|
773
963
|
requestMetadata.delete(c.req.raw);
|
|
774
964
|
return c.json({
|
|
775
965
|
type: "error",
|
|
@@ -779,31 +969,34 @@ export async function createProxyStartApp(params) {
|
|
|
779
969
|
},
|
|
780
970
|
}, 502);
|
|
781
971
|
});
|
|
782
|
-
app.
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
finishActivity();
|
|
796
|
-
requestMetadata.delete(c.req.raw);
|
|
797
|
-
};
|
|
798
|
-
try {
|
|
799
|
-
await next();
|
|
800
|
-
c.res = trackProxyResponse(c.res, finish);
|
|
972
|
+
app.post("/internal/update-control", async (c) => {
|
|
973
|
+
const suppliedToken = c.req.header("x-neurolink-update-token");
|
|
974
|
+
const expectedToken = params.updateControlToken ?? PROXY_UPDATE_CONTROL_TOKEN;
|
|
975
|
+
if (!suppliedToken || suppliedToken !== expectedToken) {
|
|
976
|
+
return c.json({ error: "not_found" }, 404);
|
|
977
|
+
}
|
|
978
|
+
const payload = await c.req
|
|
979
|
+
.json()
|
|
980
|
+
.catch(() => ({ action: undefined }));
|
|
981
|
+
if (payload.action === "drain") {
|
|
982
|
+
if (!markProxyDrainingForUpdate(readiness)) {
|
|
983
|
+
return c.json({ error: "proxy_not_ready" }, 409);
|
|
984
|
+
}
|
|
801
985
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
986
|
+
else if (payload.action === "resume") {
|
|
987
|
+
if (!resumeProxyConnections(readiness)) {
|
|
988
|
+
return c.json({ error: "proxy_not_ready" }, 409);
|
|
989
|
+
}
|
|
805
990
|
}
|
|
991
|
+
else {
|
|
992
|
+
return c.json({ error: "invalid_action" }, 400);
|
|
993
|
+
}
|
|
994
|
+
return c.json({
|
|
995
|
+
draining: readiness.drainingForUpdate,
|
|
996
|
+
acceptingConnections: readiness.acceptingConnections,
|
|
997
|
+
});
|
|
806
998
|
});
|
|
999
|
+
registerProxyRequestTracking(app, requestMetadata, readiness);
|
|
807
1000
|
const runtimeConfigStore = params.runtimeConfigStore;
|
|
808
1001
|
const runtimeConfigProvider = runtimeConfigStore
|
|
809
1002
|
? () => runtimeConfigStore.getSnapshot()
|
|
@@ -819,6 +1012,24 @@ export async function createProxyStartApp(params) {
|
|
|
819
1012
|
for (const route of allProxyRoutes) {
|
|
820
1013
|
const method = route.method.toLowerCase();
|
|
821
1014
|
app[method](route.path, async (c) => {
|
|
1015
|
+
const metadata = requestMetadata.get(c.req.raw);
|
|
1016
|
+
if (metadata?.rejectForUpdate) {
|
|
1017
|
+
if (metadata) {
|
|
1018
|
+
metadata.terminalErrorType = "proxy_draining";
|
|
1019
|
+
await recordRuntimeError(metadata, 503, "proxy_draining", "Proxy is draining for an automatic update", {
|
|
1020
|
+
clientMessage: "Proxy is restarting; retry shortly",
|
|
1021
|
+
clientErrorType: "overloaded_error",
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
c.header("Retry-After", "2");
|
|
1025
|
+
return c.json({
|
|
1026
|
+
type: "error",
|
|
1027
|
+
error: {
|
|
1028
|
+
type: "overloaded_error",
|
|
1029
|
+
message: "Proxy is restarting; retry shortly",
|
|
1030
|
+
},
|
|
1031
|
+
}, 503);
|
|
1032
|
+
}
|
|
822
1033
|
const emptyBody = {};
|
|
823
1034
|
let body;
|
|
824
1035
|
let rawBody;
|
|
@@ -849,7 +1060,6 @@ export async function createProxyStartApp(params) {
|
|
|
849
1060
|
const toolCount = Array.isArray(bodyRec?.tools)
|
|
850
1061
|
? bodyRec.tools.length
|
|
851
1062
|
: 0;
|
|
852
|
-
const metadata = requestMetadata.get(c.req.raw);
|
|
853
1063
|
if (metadata) {
|
|
854
1064
|
metadata.model = String(model);
|
|
855
1065
|
metadata.stream = stream === "stream";
|
|
@@ -1042,6 +1252,9 @@ export async function createProxyStartApp(params) {
|
|
|
1042
1252
|
lastActivityAt: activity.lastActivityAt?.toISOString() ?? null,
|
|
1043
1253
|
};
|
|
1044
1254
|
})(),
|
|
1255
|
+
observability: {
|
|
1256
|
+
lifecycle: getProxyLifecycleLoggerSnapshot(),
|
|
1257
|
+
},
|
|
1045
1258
|
autoUpdate: {
|
|
1046
1259
|
enabled: isProxyAutoUpdateEnabled(),
|
|
1047
1260
|
updaterPid: runtimeState?.updaterPid ?? null,
|
|
@@ -1051,6 +1264,7 @@ export async function createProxyStartApp(params) {
|
|
|
1051
1264
|
liveVersion: PROXY_VERSION,
|
|
1052
1265
|
latestVersion: updateState?.lastCheckVersion || null,
|
|
1053
1266
|
pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
|
|
1267
|
+
deferredUpdate: updateState?.deferredUpdate ?? null,
|
|
1054
1268
|
lastCheckAt: updateState?.lastCheckAt ?? null,
|
|
1055
1269
|
lastUpdateAt: updateState?.lastUpdateAt ?? null,
|
|
1056
1270
|
lastUpdateVersion: updateState?.lastUpdateVersion ?? null,
|
|
@@ -1339,6 +1553,12 @@ function registerProxyShutdownHandlers(params) {
|
|
|
1339
1553
|
exitCode = 1;
|
|
1340
1554
|
logger.error(`[proxy] failed to drain server during shutdown: ${error instanceof Error ? error.message : String(error)}`);
|
|
1341
1555
|
}
|
|
1556
|
+
try {
|
|
1557
|
+
await withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown");
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
logger.debug(`[proxy] lifecycle metadata flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
|
|
1561
|
+
}
|
|
1342
1562
|
try {
|
|
1343
1563
|
const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
|
|
1344
1564
|
await flushOpenTelemetry();
|
|
@@ -1848,6 +2068,7 @@ export const proxyStatusCommand = {
|
|
|
1848
2068
|
updaterRunning: false,
|
|
1849
2069
|
latestVersion: updateState?.lastCheckVersion || null,
|
|
1850
2070
|
pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
|
|
2071
|
+
deferredUpdate: updateState?.deferredUpdate ?? null,
|
|
1851
2072
|
lastUpdateFailure: updateState?.lastFailure ?? null,
|
|
1852
2073
|
};
|
|
1853
2074
|
if (state && isProcessRunning(state.pid)) {
|
|
@@ -1927,6 +2148,12 @@ export const proxyStatusCommand = {
|
|
|
1927
2148
|
if (status.pendingRestartVersion) {
|
|
1928
2149
|
logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
|
|
1929
2150
|
}
|
|
2151
|
+
if (status.deferredUpdate) {
|
|
2152
|
+
const active = status.deferredUpdate.activeRequests === null
|
|
2153
|
+
? "unknown activity"
|
|
2154
|
+
: `${status.deferredUpdate.activeRequests} active request(s)`;
|
|
2155
|
+
logger.always(` ${chalk.bold("Deferred:")} ${chalk.yellow(`v${status.deferredUpdate.version} ${status.deferredUpdate.reason} (${active})`)}`);
|
|
2156
|
+
}
|
|
1930
2157
|
if (status.latestVersion) {
|
|
1931
2158
|
logger.always(` ${chalk.bold("Latest:")} ${chalk.cyan(`v${status.latestVersion}`)}`);
|
|
1932
2159
|
}
|
|
@@ -2109,6 +2336,10 @@ export const proxyGuardCommand = {
|
|
|
2109
2336
|
// foreground fail-open guard remains cleanup-only.
|
|
2110
2337
|
const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
2111
2338
|
const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
|
|
2339
|
+
const NATURAL_WINDOW_WAIT_MS = 10 * 60 * 1000; // prefer no admission pause
|
|
2340
|
+
const UPDATE_DRAIN_TIMEOUT_MS = 30 * 60 * 1000; // preserve long streams
|
|
2341
|
+
const UPDATE_RETRY_DELAY_MS = 5 * 60 * 1000;
|
|
2342
|
+
const UPDATE_ACTIVITY_POLL_MS = 10 * 1000;
|
|
2112
2343
|
const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
|
|
2113
2344
|
// Get running version from /health endpoint (with timeout to avoid hanging)
|
|
2114
2345
|
let runningVersion = PROXY_VERSION; // fallback
|
|
@@ -2131,6 +2362,7 @@ export const proxyGuardCommand = {
|
|
|
2131
2362
|
let guardStopping = false;
|
|
2132
2363
|
let updateCheckTimeout;
|
|
2133
2364
|
let updateCheckInterval;
|
|
2365
|
+
let updateRetryTimeout;
|
|
2134
2366
|
const stopUpdateChecks = () => {
|
|
2135
2367
|
guardStopping = true;
|
|
2136
2368
|
if (updateCheckTimeout) {
|
|
@@ -2141,6 +2373,10 @@ export const proxyGuardCommand = {
|
|
|
2141
2373
|
clearInterval(updateCheckInterval);
|
|
2142
2374
|
updateCheckInterval = undefined;
|
|
2143
2375
|
}
|
|
2376
|
+
if (updateRetryTimeout) {
|
|
2377
|
+
clearTimeout(updateRetryTimeout);
|
|
2378
|
+
updateRetryTimeout = undefined;
|
|
2379
|
+
}
|
|
2144
2380
|
};
|
|
2145
2381
|
/** Keep state-write failures observable without terminating the updater. */
|
|
2146
2382
|
const persistUpdaterState = (operation, action) => {
|
|
@@ -2159,41 +2395,93 @@ export const proxyGuardCommand = {
|
|
|
2159
2395
|
}
|
|
2160
2396
|
updateInProgress = true;
|
|
2161
2397
|
let updateVersion = runningVersion;
|
|
2398
|
+
let drainActive = false;
|
|
2162
2399
|
try {
|
|
2163
2400
|
// Lazy-load update modules so they're only imported at check time
|
|
2164
2401
|
const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
|
|
2165
2402
|
// 1. Check for update
|
|
2166
|
-
|
|
2403
|
+
let result = await checkForUpdate(runningVersion);
|
|
2167
2404
|
updateVersion = result.latestVersion;
|
|
2168
2405
|
persistUpdaterState("record update check", () => recordCheck(result.latestVersion));
|
|
2169
2406
|
if (!result.updateAvailable) {
|
|
2407
|
+
persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
|
|
2170
2408
|
return;
|
|
2171
2409
|
}
|
|
2172
|
-
const
|
|
2173
|
-
if (isVersionSuppressed(result.latestVersion) &&
|
|
2410
|
+
const initiallyPendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
|
|
2411
|
+
if (isVersionSuppressed(result.latestVersion) &&
|
|
2412
|
+
!initiallyPendingRestart) {
|
|
2174
2413
|
logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
|
|
2175
2414
|
return;
|
|
2176
2415
|
}
|
|
2177
2416
|
logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
|
|
2178
|
-
// 2.
|
|
2179
|
-
// a
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2417
|
+
// 2. Prefer a naturally quiet period. Sustained traffic is handled by
|
|
2418
|
+
// a bounded graceful drain so update checks cannot wait forever.
|
|
2419
|
+
let lastDeferralSignature = "";
|
|
2420
|
+
const waitForWindow = async (quietWaitMs) => {
|
|
2421
|
+
const window = await waitForProxyUpdateWindow({
|
|
2422
|
+
quietThresholdMs: QUIET_THRESHOLD_MS,
|
|
2423
|
+
quietWaitMs,
|
|
2424
|
+
drainWaitMs: UPDATE_DRAIN_TIMEOUT_MS,
|
|
2425
|
+
pollIntervalMs: UPDATE_ACTIVITY_POLL_MS,
|
|
2426
|
+
getActivity: () => getProxyRuntimeActivity(host, port),
|
|
2427
|
+
setDraining: async (draining) => {
|
|
2428
|
+
const changed = await setProxyUpdateDrain(host, port, draining);
|
|
2429
|
+
if (changed) {
|
|
2430
|
+
logger.always(`[updater] ${draining ? "draining new inference requests" : "inference admission resumed"}`);
|
|
2431
|
+
}
|
|
2432
|
+
return changed;
|
|
2433
|
+
},
|
|
2434
|
+
isStopping: () => guardStopping,
|
|
2435
|
+
isParentAlive: () => getProcessStatus(parentPid) !== "not_running",
|
|
2436
|
+
onPhase: (phase, activity) => {
|
|
2437
|
+
const reason = phase === "waiting_for_quiet" && !activity
|
|
2438
|
+
? "activity_unavailable"
|
|
2439
|
+
: phase;
|
|
2440
|
+
const signature = `${reason}:${activity?.activeRequests ?? "unknown"}`;
|
|
2441
|
+
if (signature !== lastDeferralSignature) {
|
|
2442
|
+
lastDeferralSignature = signature;
|
|
2443
|
+
persistUpdaterState("record update deferral", () => recordUpdateDeferred(result.latestVersion, reason, activity?.activeRequests ?? null));
|
|
2444
|
+
}
|
|
2445
|
+
logger.debug(`[updater] ${reason} (${activity?.activeRequests ?? "unknown"} active requests)`);
|
|
2446
|
+
},
|
|
2447
|
+
});
|
|
2448
|
+
drainActive = drainActive || window.draining;
|
|
2449
|
+
return window;
|
|
2450
|
+
};
|
|
2451
|
+
let updateWindow = await waitForWindow(NATURAL_WINDOW_WAIT_MS);
|
|
2452
|
+
if (!updateWindow.ready) {
|
|
2453
|
+
if (updateWindow.reason === "drain_failed") {
|
|
2454
|
+
persistUpdaterState("record unavailable update drain", () => recordUpdateDeferred(result.latestVersion, "drain_unavailable", null));
|
|
2185
2455
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2456
|
+
scheduleUpdateRetry();
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
// Close the small race between observing zero activity and package
|
|
2460
|
+
// mutation by holding admission closed through install and restart.
|
|
2461
|
+
if (!drainActive) {
|
|
2462
|
+
updateWindow = await waitForWindow(0);
|
|
2463
|
+
if (!updateWindow.ready) {
|
|
2464
|
+
scheduleUpdateRetry();
|
|
2465
|
+
return;
|
|
2190
2466
|
}
|
|
2191
|
-
logger.debug(`[updater] waiting for idle traffic (${activity?.activeRequests ?? "unknown"} active requests)`);
|
|
2192
|
-
await new Promise((r) => setTimeout(r, quietPollMs));
|
|
2193
2467
|
}
|
|
2194
|
-
|
|
2468
|
+
// Refresh after waiting so a long deferral can never install a stale
|
|
2469
|
+
// target while a newer release is already available.
|
|
2470
|
+
const refreshedResult = await checkForUpdate(runningVersion);
|
|
2471
|
+
result = refreshedResult;
|
|
2472
|
+
updateVersion = result.latestVersion;
|
|
2473
|
+
persistUpdaterState("record refreshed update check", () => recordCheck(result.latestVersion));
|
|
2474
|
+
if (!result.updateAvailable) {
|
|
2475
|
+
persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
|
|
2195
2476
|
return;
|
|
2196
2477
|
}
|
|
2478
|
+
const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
|
|
2479
|
+
if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
|
|
2480
|
+
logger.debug(`[guard] refreshed version ${result.latestVersion} is suppressed, skipping`);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
|
|
2484
|
+
logger.always(`[updater] safe update window acquired for v${result.latestVersion}`);
|
|
2197
2485
|
// 3. Install update (validate version string before passing to shell)
|
|
2198
2486
|
if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
|
|
2199
2487
|
const message = `invalid version format: ${result.latestVersion}`;
|
|
@@ -2266,22 +2554,8 @@ export const proxyGuardCommand = {
|
|
|
2266
2554
|
if (guardStopping || getProcessStatus(parentPid) === "not_running") {
|
|
2267
2555
|
return;
|
|
2268
2556
|
}
|
|
2269
|
-
//
|
|
2270
|
-
//
|
|
2271
|
-
while (!guardStopping) {
|
|
2272
|
-
if (getProcessStatus(parentPid) === "not_running") {
|
|
2273
|
-
return;
|
|
2274
|
-
}
|
|
2275
|
-
const activity = await getProxyRuntimeActivity(host, port);
|
|
2276
|
-
if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
|
|
2277
|
-
break;
|
|
2278
|
-
}
|
|
2279
|
-
logger.debug(`[updater] update installed; deferring restart (${activity?.activeRequests ?? "unknown"} active requests)`);
|
|
2280
|
-
await sleep(10_000);
|
|
2281
|
-
}
|
|
2282
|
-
if (guardStopping) {
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2557
|
+
// Admission has remained closed since active requests reached zero, so
|
|
2558
|
+
// restart cannot interrupt an accepted request or long-lived stream.
|
|
2285
2559
|
// Signal the health loop to not exit when it detects
|
|
2286
2560
|
// the parent PID is gone — we're intentionally restarting.
|
|
2287
2561
|
updateRestartInProgress = true;
|
|
@@ -2302,6 +2576,7 @@ export const proxyGuardCommand = {
|
|
|
2302
2576
|
persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", msg));
|
|
2303
2577
|
return;
|
|
2304
2578
|
}
|
|
2579
|
+
drainActive = false;
|
|
2305
2580
|
// 5. Wait for healthy restart
|
|
2306
2581
|
let healthy = false;
|
|
2307
2582
|
const restartStart = Date.now();
|
|
@@ -2343,9 +2618,25 @@ export const proxyGuardCommand = {
|
|
|
2343
2618
|
persistUpdaterState("record update failure", () => recordUpdateFailure(updateVersion, "check", message));
|
|
2344
2619
|
}
|
|
2345
2620
|
finally {
|
|
2621
|
+
if (drainActive && !updateRestartInProgress) {
|
|
2622
|
+
const resumed = await resumeProxyUpdateDrain(host, port);
|
|
2623
|
+
if (!resumed && getProcessStatus(parentPid) !== "not_running") {
|
|
2624
|
+
logger.always(`[updater] WARNING: failed to resume inference admission after deferred update`);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2346
2627
|
updateInProgress = false;
|
|
2347
2628
|
}
|
|
2348
2629
|
};
|
|
2630
|
+
const scheduleUpdateRetry = () => {
|
|
2631
|
+
if (guardStopping || updateRetryTimeout) {
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
updateRetryTimeout = setTimeout(() => {
|
|
2635
|
+
updateRetryTimeout = undefined;
|
|
2636
|
+
void runUpdateCheck();
|
|
2637
|
+
}, UPDATE_RETRY_DELAY_MS);
|
|
2638
|
+
updateRetryTimeout.unref?.();
|
|
2639
|
+
};
|
|
2349
2640
|
// Run first check after a short delay, then on interval
|
|
2350
2641
|
if (canAutoUpdate) {
|
|
2351
2642
|
updateCheckTimeout = setTimeout(runUpdateCheck, 30_000);
|