From 2f6dfd05be12d73e18d5952e5608b91f14ca3976 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg Date: Thu, 26 Mar 2026 19:17:10 +0000 Subject: [PATCH 1/4] fix(onboard): force chat completions API for vLLM and NIM-local providers vLLM's /v1/responses endpoint does not run the --tool-call-parser, so tool calls arrive as raw text in the response content instead of the structured tool_calls array. The probe picks openai-responses because vLLM accepts the request, but parsing only works on /v1/chat/completions. Override preferredInferenceApi to openai-completions after validation for both vllm and nim-local provider paths. Fixes #976 --- bin/lib/onboard.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 95589411338..f9b5baa8b29 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1866,6 +1866,9 @@ async function setupNim(gpu) { if (!preferredInferenceApi) { continue selectionLoop; } + // NIM uses vLLM internally — same tool-call-parser limitation + // applies to /v1/responses. Force chat completions. + preferredInferenceApi = "openai-completions"; } } break; @@ -1982,6 +1985,10 @@ async function setupNim(gpu) { if (!preferredInferenceApi) { continue selectionLoop; } + // Force chat completions — vLLM's /v1/responses endpoint does not + // run the --tool-call-parser, so tool calls arrive as raw text. + // See: https://github.com/NVIDIA/NemoClaw/issues/976 + preferredInferenceApi = "openai-completions"; break; } } From 90da54231992c3f66332a2fa2ff55b0addb2e5a6 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg Date: Thu, 26 Mar 2026 19:25:57 +0000 Subject: [PATCH 2/4] fix: log when overriding probe result to chat completions Surface the API override during onboard so users see why responses API was not selected. --- bin/lib/onboard.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index f9b5baa8b29..6f17c9a010b 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1868,6 +1868,9 @@ async function setupNim(gpu) { } // NIM uses vLLM internally — same tool-call-parser limitation // applies to /v1/responses. Force chat completions. + if (preferredInferenceApi !== "openai-completions") { + console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); + } preferredInferenceApi = "openai-completions"; } } @@ -1988,6 +1991,9 @@ async function setupNim(gpu) { // Force chat completions — vLLM's /v1/responses endpoint does not // run the --tool-call-parser, so tool calls arrive as raw text. // See: https://github.com/NVIDIA/NemoClaw/issues/976 + if (preferredInferenceApi !== "openai-completions") { + console.log(" ℹ Using chat completions API (tool-call-parser requires /v1/chat/completions)"); + } preferredInferenceApi = "openai-completions"; break; } From 6c43ed68b8555552d7c1702d20a6363588ca77cb Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <69834303+BenediktSchackenberg@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:40:31 +0000 Subject: [PATCH 3/4] test: add regression test for vLLM chat completions override Verifies that setupNim() forces preferredInferenceApi to openai-completions for the vLLM provider path even when the probe detects openai-responses first. This locks down the fix for #976 so the tool-call-parser override cannot silently regress. --- test/onboard-selection.test.js | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 8fceee219f0..7f75327685b 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -1304,4 +1304,103 @@ const { setupNim } = require(${onboardPath}); assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); }); + + it("forces openai-completions for vLLM even when probe detects openai-responses", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-vllm-override-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "vllm-override-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Fake curl: /v1/responses returns 200 (so probe detects openai-responses), + // /v1/models returns a vLLM model list + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models'; then + body='{"data":[{"id":"meta-llama/Llama-3.3-70B-Instruct"}]}' +elif echo "$url" | grep -q '/v1/responses'; then + body='{"id":"resp_123","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}' +elif echo "$url" | grep -q '/v1/chat/completions'; then + body='{"id":"chatcmpl-123","choices":[{"message":{"content":"ok"}}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + // vLLM is option 7 (build, openai, custom, anthropic, anthropicCompatible, gemini, vllm) + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434")) return ""; + if (command.includes("localhost:8000/v1/models")) return JSON.stringify({ data: [{ id: "meta-llama/Llama-3.3-70B-Instruct" }] }); + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "vllm-local"); + assert.equal(payload.result.model, "meta-llama/Llama-3.3-70B-Instruct"); + // Key assertion: even though probe detected openai-responses, the override + // forces openai-completions so tool-call-parser works correctly. + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.ok(payload.lines.some((line) => line.includes("Using existing vLLM"))); + assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); + }); }); From 94f8f20f4bb48a65c2e40c11d7be728aa7d0745a Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <69834303+BenediktSchackenberg@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:11:05 +0000 Subject: [PATCH 4/4] test: add NIM-local chat completions override regression test Companion test for the vLLM case: verifies that setupNim() forces openai-completions for the NIM-local path too, since NIM uses vLLM internally and has the same tool-call-parser limitation. --- test/onboard-selection.test.js | 108 +++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 7f75327685b..887d3b5c30c 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -1403,4 +1403,112 @@ const { setupNim } = require(${onboardPath}); assert.ok(payload.lines.some((line) => line.includes("Using existing vLLM"))); assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); }); + + it("forces openai-completions for NIM-local even when probe detects openai-responses", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-nim-override-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "nim-override-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const nimPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "nim.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Fake curl: /v1/responses returns 200 (probe detects openai-responses) + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models'; then + body='{"data":[{"id":"nvidia/nemotron-3-nano"}]}' +elif echo "$url" | grep -q '/v1/responses'; then + body='{"id":"resp_123","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}' +elif echo "$url" | grep -q '/v1/chat/completions'; then + body='{"id":"chatcmpl-123","choices":[{"message":{"content":"ok"}}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + // NIM-local is option 7 (build, openai, custom, anthropic, anthropicCompatible, gemini, nim-local) + // No ollama, no vLLM — only NIM-local shows up as experimental option + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +// Mock nim module before onboard.js requires it +const nimMod = require(${nimPath}); +nimMod.listModels = () => [{ name: "nvidia/nemotron-3-nano", image: "fake", minGpuMemoryMB: 8000 }]; +nimMod.pullNimImage = () => {}; +nimMod.containerName = () => "nemoclaw-nim-test"; +nimMod.startNimContainerByName = () => "container-123"; +nimMod.waitForNimHealth = () => true; + +// Select option 7 (nim-local), then model 1 +const answers = ["7", "1"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + // Pass a GPU object with nimCapable: true + const result = await setupNim({ type: "nvidia", totalMemoryMB: 16000, nimCapable: true }); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_EXPERIMENTAL: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "vllm-local"); + assert.equal(payload.result.model, "nvidia/nemotron-3-nano"); + // Key assertion: NIM uses vLLM internally — same override must apply. + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); + }); });