Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
561d29d
fix(onboard): make the routed credential own the router env and captu…
Dongni-Yang Aug 13, 2026
35ca808
fix(onboard): harden router log capture and honor a late-healthy router
Dongni-Yang Aug 13, 2026
dab4ae0
fix(onboard): keep the 3-second liveness probe for the startup poll
Dongni-Yang Aug 13, 2026
276f713
fix(onboard): keep the failed-startup budget at 600 seconds
Dongni-Yang Aug 13, 2026
eff41e8
fix(onboard): fully redact the startup-error log tail
Dongni-Yang Aug 13, 2026
4685074
test(onboard): pin the redacted log line in the startup error
Dongni-Yang Aug 13, 2026
e6f56ad
test(onboard): redact a credential-shaped health error in the startup…
Dongni-Yang Aug 13, 2026
f9f3e2d
Merge branch 'main' into fix/8962-model-router-diagnosability
rsliter Aug 13, 2026
6f98c7d
fix(onboard): prove router credential endpoints
cv Aug 13, 2026
a1f9dd3
fix(onboard): stop unproven router credentials
cv Aug 13, 2026
a41dc30
docs(inference): explain custom router key
cv Aug 13, 2026
20fa72d
test(onboard): define shipped router pool fixture
cv Aug 13, 2026
1a7dfbf
Merge remote-tracking branch 'origin/main' into codex/review-8972-sec…
cv Aug 13, 2026
22439c4
fix(onboard): narrow router credential scope
cv Aug 13, 2026
7f26e0c
docs(inference): document router log retention
cv Aug 13, 2026
fbf104f
docs(inference): qualify router log excerpts
rsliter Aug 13, 2026
8d805e4
Merge remote-tracking branch 'origin/main' into HEAD
cv Aug 13, 2026
924bf14
fix(test): preserve explicit WSL overrides
prekshivyas Aug 13, 2026
ef0eb78
Merge remote-tracking branch 'origin/fix/8962-model-router-diagnosabi…
cv Aug 13, 2026
d4b0245
fix(hermes): admit the managed API port
prekshivyas Aug 13, 2026
bae3219
test(onboard): use valid replacement identity
prekshivyas Aug 13, 2026
ce1cf71
merge: resolve conflicts with main
github-actions[bot] Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 89,
"src/lib/core/shell-quote.ts": 28,
"src/lib/core/url-utils.ts": 27,
"src/lib/core/url-utils.ts": 28,
"src/lib/core/wait.ts": 35,
"src/lib/credentials/store.ts": 46,
"src/lib/inference/config.ts": 30,
"src/lib/inference/web-search.ts": 21,
"src/lib/messaging/channels/index.ts": 25,
"src/lib/onboard/gateway-binding.ts": 51,
"src/lib/runner.ts": 88,
"src/lib/security/redact.ts": 52,
"src/lib/security/redact.ts": 53,
"src/lib/state/onboard-session.ts": 36,
"src/lib/state/registry.ts": 101,
"src/lib/state/state-root.ts": 21,
Expand Down
4 changes: 4 additions & 0 deletions docs/inference/set-up-model-router.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ During onboarding, NemoClaw starts the router proxy on host port `4000`, waits f
The sandbox does not call port `4000` directly.
On a host without cached routing-model files, Model Router downloads and loads its routing model before the health endpoint responds.
NemoClaw gives a running router up to 10 minutes to pass its health check and stops onboarding sooner if the process exits.
NemoClaw writes the router process output to `~/.nemoclaw/state/model-router.log` and creates that file with owner-only permissions.
A nondefault gateway port writes the same file under `~/.nemoclaw/gateways/<port>/state/model-router.log`.
If the router does not pass its health check in time, the startup error reports that log path and the first unhealthy-endpoint error that the router returned.
Review the log before you share it, because it contains unredacted router output.

```text
Sandbox agent -> OpenShell -> Model Router on port 4000 -> NVIDIA API
Expand Down
98 changes: 97 additions & 1 deletion src/lib/onboard/model-router-process.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,105 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import * as http from "node:http";
import type { AddressInfo } from "node:net";

import { describe, expect, it, vi } from "vitest";

import { findModelRouterPidForPort, stopModelRouterProcess } from "./model-router-process";
import {
findModelRouterPidForPort,
getRouterHealthSnapshot,
stopModelRouterProcess,
} from "./model-router-process";

async function withHealthServer(
handler: http.RequestListener,
run: (port: number) => Promise<void>,
): Promise<void> {
const server = http.createServer(handler);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await run((server.address() as AddressInfo).port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

describe("getRouterHealthSnapshot (#8962)", () => {
it("captures the /health body alongside a 2xx status", async () => {
const body = JSON.stringify({
healthy_endpoints: [],
unhealthy_endpoints: [{ error: "AuthenticationError: bad key" }],
});
await withHealthServer(
(_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(body);
},
async (port) => {
const snapshot = await getRouterHealthSnapshot(port);
expect(snapshot).toEqual({ healthy: true, body });
},
);
});

it("reports unhealthy with the body for a non-2xx response", async () => {
await withHealthServer(
(_req, res) => {
res.writeHead(503, { "content-type": "text/plain" });
res.end("router warming up");
},
async (port) => {
const snapshot = await getRouterHealthSnapshot(port);
expect(snapshot).toEqual({ healthy: false, body: "router warming up" });
},
);
});

it("reports unhealthy with no body when the connection is reset", async () => {
const server = http.createServer();
server.on("connection", (socket) => socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
try {
const snapshot = await getRouterHealthSnapshot(port);
expect(snapshot).toEqual({ healthy: false, body: null });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it("settles at the wall-clock deadline with the partial body of a trickling response", async () => {
await withHealthServer(
(_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.write('{"healthy_endpoints":[],"unhealthy_endpoints":[{"error":"partial');
// Never end the response; the wall-clock deadline must settle it.
},
async (port) => {
const snapshot = await getRouterHealthSnapshot(port, 300);
expect(snapshot.healthy).toBe(true);
expect(snapshot.body).toContain('"error":"partial');
},
);
});

it("settles at the capture cap with the truncated body prefix", async () => {
const oversized = `{"unhealthy_endpoints":[{"error":"big"}],"pad":"${"x".repeat(70 * 1024)}"}`;
await withHealthServer(
(_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(oversized);
},
async (port) => {
const snapshot = await getRouterHealthSnapshot(port);
expect(snapshot.healthy).toBe(true);
expect(snapshot.body?.length).toBe(64 * 1024);
expect(snapshot.body).toContain('"error":"big"');
},
);
});
});

const ROUTER_ARGS = ["/opt/model-router", "proxy", "--port", "4000"];

Expand Down
57 changes: 57 additions & 0 deletions src/lib/onboard/model-router-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,63 @@ type ModelRouterCommandLineReaderDeps = {
listProcPids?: () => number[];
};

export type RouterHealthSnapshot = {
healthy: boolean;
body: string | null;
};

const ROUTER_HEALTH_BODY_MAX_BYTES = 64 * 1024;

/**
* Fetch /health and keep the response body for diagnosis (#8962). Unlike
* `isRouterHealthy`, this waits for the body, so callers pass a longer
* timeout; `startModelRouter` uses 30 seconds. LiteLLM's /health probes
* every upstream endpoint per request and can answer well after the
* 3-second liveness budget. The timeout is a wall-clock deadline, not a
* socket idle timeout, so a responder that trickles bytes cannot hold the
* caller past it; whatever body arrived by then is returned.
*/
export async function getRouterHealthSnapshot(
port: number,
timeoutMs = ROUTER_HEALTH_TIMEOUT_MS,
): Promise<RouterHealthSnapshot> {
return new Promise<RouterHealthSnapshot>((resolve) => {
let settled = false;
const chunks: Buffer[] = [];
let size = 0;
let responseHealthy = false;
const bufferedBody = () => (chunks.length > 0 ? Buffer.concat(chunks).toString("utf8") : null);
const settle = (snapshot: RouterHealthSnapshot) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve(snapshot);
};
const request = http
.get(`http://127.0.0.1:${port}/health`, (res: http.IncomingMessage) => {
responseHealthy = (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300;
res.on("data", (chunk: Buffer) => {
const remaining = ROUTER_HEALTH_BODY_MAX_BYTES - size;
const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
chunks.push(kept);
size += kept.length;
if (size >= ROUTER_HEALTH_BODY_MAX_BYTES) {
res.destroy();
settle({ healthy: responseHealthy, body: bufferedBody() });
}
});
res.on("end", () => settle({ healthy: responseHealthy, body: bufferedBody() }));
res.on("error", () => settle({ healthy: responseHealthy, body: bufferedBody() }));
})
.on("error", () => settle({ healthy: responseHealthy, body: bufferedBody() }));
const deadline = setTimeout(() => {
request.destroy();
settle({ healthy: responseHealthy, body: bufferedBody() });
}, timeoutMs);
deadline.unref?.();
});
}

export async function isRouterHealthy(
port: number,
timeoutMs = ROUTER_HEALTH_TIMEOUT_MS,
Expand Down
Loading
Loading