Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
74 changes: 74 additions & 0 deletions src/lib/adapters/http/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,80 @@ describe("http-probe helpers", () => {
expect(fs.existsSync(path.dirname(outputPath))).toBe(false);
});

it("lets the process wrapper outlive curl --max-time", () => {
let timeout: number | undefined;
const result = runCurlProbe(["-sS", "--max-time", "60", "https://example.test/models"], {
spawnSyncImpl: (_command, args, options) => {
timeout = options.timeout;
const outputPath = args[args.indexOf("-o") + 1];
if (typeof outputPath === "string") {
fs.writeFileSync(outputPath, "{}");
}
return {
pid: 1,
output: [],
stdout: "200",
stderr: "",
status: 0,
signal: null,
};
},
});

expect(result.ok).toBe(true);
expect(timeout).toBe(65_000);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("uses the last curl --max-time when the flag is repeated", () => {
let timeout: number | undefined;
runCurlProbe(
["-sS", "--max-time", "15", "--max-time", "120", "https://example.test/models"],
{
spawnSyncImpl: (_command, args, options) => {
timeout = options.timeout;
const outputPath = args[args.indexOf("-o") + 1];
if (typeof outputPath === "string") {
fs.writeFileSync(outputPath, "{}");
}
return {
pid: 1,
output: [],
stdout: "200",
stderr: "",
status: 0,
signal: null,
};
},
},
);

expect(timeout).toBe(125_000);
});

it("honors an explicit process timeout over inferred curl --max-time", () => {
let timeout: number | undefined;
runCurlProbe(["-sS", "--max-time", "60", "https://example.test/models"], {
timeoutMs: 12_345,
spawnSyncImpl: (_command, args, options) => {
timeout = options.timeout;
const outputPath = args[args.indexOf("-o") + 1];
if (typeof outputPath === "string") {
fs.writeFileSync(outputPath, "{}");
}
return {
pid: 1,
output: [],
stdout: "200",
stderr: "",
status: 0,
signal: null,
};
},
});

expect(timeout).toBe(12_345);
});

it("reports spawn errors as curl failures", () => {
const result = runCurlProbe(["-sS", "https://example.test/models"], {
spawnSyncImpl: () => {
Expand Down
45 changes: 41 additions & 4 deletions src/lib/adapters/http/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface StreamingProbeResult {
message: string;
}

const DEFAULT_CURL_PROCESS_TIMEOUT_MS = 30_000;
const CURL_PROCESS_TIMEOUT_SLACK_MS = 5_000;

function validateTempPrefix(prefix: string): string {
if (
prefix.length === 0 ||
Expand Down Expand Up @@ -69,6 +72,37 @@ export function getCurlTimingArgs(): string[] {
return ["--connect-timeout", "10", "--max-time", "60"];
}

function getCurlMaxTimeSeconds(argv: string[]): number | null {
let maxTimeSeconds: number | null = null;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--max-time") {
const value = Number(argv[index + 1]);
if (Number.isFinite(value) && value > 0) {
maxTimeSeconds = value;
}
continue;
}
if (arg.startsWith("--max-time=")) {
const value = Number(arg.slice("--max-time=".length));
if (Number.isFinite(value) && value > 0) {
maxTimeSeconds = value;
}
}
}
return maxTimeSeconds;
}

function resolveCurlProcessTimeoutMs(argv: string[], opts: CurlProbeOptions): number {
if (opts.timeoutMs !== undefined) return opts.timeoutMs;
const maxTimeSeconds = getCurlMaxTimeSeconds(argv);
if (maxTimeSeconds === null) return DEFAULT_CURL_PROCESS_TIMEOUT_MS;
return Math.max(
DEFAULT_CURL_PROCESS_TIMEOUT_MS,
Math.ceil(maxTimeSeconds * 1000) + CURL_PROCESS_TIMEOUT_SLACK_MS,
);
}

function sanitizeCurlUrl(value: string): string {
try {
const url = new URL(value);
Expand All @@ -92,7 +126,7 @@ function getCurlProbeTraceAttributes(argv: string[], opts: CurlProbeOptions): Re
return {
"http.url": sanitizeCurlUrl(String(url)),
"http.request.method": method,
"process.timeout_ms": opts.timeoutMs ?? 30_000,
"process.timeout_ms": resolveCurlProcessTimeoutMs(argv, opts),
};
}

Expand Down Expand Up @@ -173,13 +207,14 @@ function runCurlProbeImpl(argv: string[], opts: CurlProbeOptions = {}): CurlProb
const args = [...argv];
const url = args.pop();
const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync;
const timeout = resolveCurlProcessTimeoutMs(argv, opts);
const result = spawnSyncImpl(
"curl",
[...args, "-o", bodyFile, "-w", "%{http_code}", String(url || "")],
{
cwd: opts.cwd ?? ROOT,
encoding: "utf8",
timeout: opts.timeoutMs ?? 30_000,
timeout,
env: opts.replaceEnv ? (opts.env ?? {}) : { ...process.env, ...opts.env },
},
);
Expand Down Expand Up @@ -283,13 +318,14 @@ function runChatCompletionsStreamingProbeImpl(
const args = [...argv];
const url = args.pop();
const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync;
const timeout = resolveCurlProcessTimeoutMs(argv, opts);
const result = spawnSyncImpl(
"curl",
[...args, "-N", "-o", bodyFile, "-w", "%{http_code}", String(url || "")],
{
cwd: opts.cwd ?? ROOT,
encoding: "utf8",
timeout: opts.timeoutMs ?? 30_000,
timeout,
env: {
...process.env,
...opts.env,
Expand Down Expand Up @@ -405,10 +441,11 @@ function runStreamingEventProbeImpl(
const args = [...argv];
const url = args.pop();
const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync;
const timeout = resolveCurlProcessTimeoutMs(argv, opts);
const result = spawnSyncImpl("curl", [...args, "-N", "-o", bodyFile, String(url || "")], {
cwd: opts.cwd ?? ROOT,
encoding: "utf8",
timeout: opts.timeoutMs ?? 30_000,
timeout,
env: {
...process.env,
...opts.env,
Expand Down
48 changes: 48 additions & 0 deletions src/lib/inference/onboard-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
getChatCompletionsProbePayload,
getDeepSeekV4ProValidationProbeCurlArgs,
getKimiK26ValidationProbeCurlArgs,
getValidationProbeCurlArgs,
hasChatCompletionsToolCall,
hasChatCompletionsToolCallLeak,
hasResponsesToolCall,
Expand Down Expand Up @@ -289,6 +290,53 @@ describe("OpenAI-compatible inference probes", () => {
});
});

it("allows onboard validation max-time to be raised from the environment", () => {
const original = process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS;
process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS = "300";
try {
expect(getValidationProbeCurlArgs({ isWsl: false })).toEqual([
"--connect-timeout",
"10",
"--max-time",
"300",
]);
expect(getKimiK26ValidationProbeCurlArgs({ isWsl: false })).toEqual([
"--connect-timeout",
"10",
"--max-time",
"300",
]);
} finally {
if (original === undefined) {
delete process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS;
} else {
process.env.NEMOCLAW_ONBOARD_VALIDATION_TIMEOUT_SECONDS = original;
}
}
});

it("uses an extended validation budget for slow NVIDIA Build models", () => {
for (const model of ["qwen/qwen3.5-397b-a17b", "deepseek-ai/deepseek-v4-flash"]) {
const args = getChatCompletionsProbeCurlArgs({
authHeader: ["-H", "Authorization: Bearer nvapi-test"],
model,
url: "https://integrate.api.nvidia.com/v1/chat/completions",
isWsl: false,
});
expect(args[args.indexOf("--connect-timeout") + 1]).toBe("10");
expect(args[args.indexOf("--max-time") + 1]).toBe("300");
}

const wslArgs = getChatCompletionsProbeCurlArgs({
authHeader: ["-H", "Authorization: Bearer nvapi-test"],
model: "qwen/qwen3.5-397b-a17b",
url: "https://integrate.api.nvidia.com/v1/chat/completions",
isWsl: true,
});
expect(wslArgs[wslArgs.indexOf("--connect-timeout") + 1]).toBe("30");
expect(wslArgs[wslArgs.indexOf("--max-time") + 1]).toBe("300");
});

it("caps Kimi K2.6 probe output and gives it a slower validation budget", () => {
expect(getChatCompletionsProbePayload("moonshotai/kimi-k2.6")).toEqual({
model: "moonshotai/kimi-k2.6",
Expand Down
Loading