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
371 changes: 371 additions & 0 deletions src/lib/onboard-inference-probes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,371 @@
// @ts-nocheck
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Inference endpoint probes — validate that a provider's API responds
// before committing the onboard wizard to a model selection.

const { normalizeCredentialValue } = require("./credentials");
const { isWsl } = require("./platform");
const httpProbe = require("./http-probe");
const {
isNvcfFunctionNotFoundForAccount,
nvcfFunctionNotFoundMessage,
shouldForceCompletionsApi,
} = require("./validation");

const { getCurlTimingArgs, runCurlProbe, runStreamingEventProbe } = httpProbe;

// ── Helpers ──────────────────────────────────────────────────────

function parseJsonObject(body) {
if (!body) return null;
try {
return JSON.parse(body);
} catch {
return null;
}
}

function hasResponsesToolCall(body) {
const parsed = parseJsonObject(body);
if (!parsed || !Array.isArray(parsed.output)) return false;

const stack = [...parsed.output];
while (stack.length > 0) {
const item = stack.pop();
if (!item || typeof item !== "object") continue;
if (item.type === "function_call" || item.type === "tool_call") return true;
if (Array.isArray(item.content)) {
stack.push(...item.content);
}
}

return false;
}

function shouldRequireResponsesToolCalling(provider) {
return (
provider === "nvidia-prod" || provider === "gemini-api" || provider === "compatible-endpoint"
);
}

// Google Gemini rejects requests that carry both an Authorization: Bearer
// The Gemini OpenAI-compat endpoint at /v1beta/openai/ requires
// `Authorization: Bearer <KEY>` and rejects `?key=<KEY>` with HTTP 400
// "Missing or invalid Authorization header." The dual-auth rejection
// described in #1960 applies to the native /v1beta/models/...:generateContent
// endpoint, which the onboarder probes do not use. Both callers of this
// helper (probeOpenAiLikeEndpoint, probeResponsesToolCalling) target the
// OpenAI-compat URL, so returning undefined for every provider is correct:
// probes default to Bearer auth and Gemini onboarding succeeds.
function getProbeAuthMode(_provider) {
return undefined;
}

// Per-validation-probe curl timing. Tighter than the default 60s in
// getCurlTimingArgs() because validation must not hang the wizard for a
// minute on a misbehaving model. See issue #1601 (Bug 3).
function getValidationProbeCurlArgs(opts) {
if (isWsl(opts)) {
return ["--connect-timeout", "20", "--max-time", "30"];
}
return ["--connect-timeout", "10", "--max-time", "15"];
}

// ── Responses API probe ──────────────────────────────────────────

function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) {
const useQueryParam = options.authMode === "query-param";
const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : "";
const baseUrl = String(endpointUrl).replace(/\/+$/, "");
const authHeader = !useQueryParam && normalizedKey
? ["-H", `Authorization: Bearer ${normalizedKey}`]
: [];
const url = useQueryParam && normalizedKey
? `${baseUrl}/responses?key=${encodeURIComponent(normalizedKey)}`
: `${baseUrl}/responses`;
const result = runCurlProbe([
"-sS",
...getValidationProbeCurlArgs(),
"-H",
"Content-Type: application/json",
...authHeader,
"-d",
JSON.stringify({
model,
input: "Call the emit_ok function with value OK. Do not answer with plain text.",
tool_choice: "required",
tools: [
{
type: "function",
name: "emit_ok",
description: "Returns the probe value for validation.",
parameters: {
type: "object",
properties: {
value: { type: "string" },
},
required: ["value"],
additionalProperties: false,
},
},
],
}),
url,
]);

if (!result.ok) {
return result;
}
if (hasResponsesToolCall(result.body)) {
return result;
}
return {
ok: false,
httpStatus: result.httpStatus,
curlStatus: result.curlStatus,
body: result.body,
stderr: result.stderr,
message: `HTTP ${result.httpStatus}: Responses API did not return a tool call`,
};
}

// ── OpenAI-like probe ────────────────────────────────────────────
// eslint-disable-next-line complexity
function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) {
const useQueryParam = options.authMode === "query-param";
const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : "";
const baseUrl = String(endpointUrl).replace(/\/+$/, "");
const authHeader = !useQueryParam && normalizedKey
? ["-H", `Authorization: Bearer ${normalizedKey}`]
: [];
const appendKey = (urlPath) =>
useQueryParam && normalizedKey ? `${baseUrl}${urlPath}?key=${encodeURIComponent(normalizedKey)}` : `${baseUrl}${urlPath}`;

const responsesProbe =
options.requireResponsesToolCalling === true
? {
name: "Responses API with tool calling",
api: "openai-responses",
execute: () => probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }),
}
: {
name: "Responses API",
api: "openai-responses",
execute: () =>
runCurlProbe([
"-sS",
...getValidationProbeCurlArgs(),
"-H",
"Content-Type: application/json",
...authHeader,
"-d",
JSON.stringify({
model,
input: "Reply with exactly: OK",
}),
appendKey("/responses"),
]),
};

const chatCompletionsProbe = {
name: "Chat Completions API",
api: "openai-completions",
execute: () =>
runCurlProbe([
"-sS",
...getValidationProbeCurlArgs(),
"-H",
"Content-Type: application/json",
...authHeader,
"-d",
JSON.stringify({
model,
messages: [{ role: "user", content: "Reply with exactly: OK" }],
}),
appendKey("/chat/completions"),
]),
};

// NVIDIA Build does not expose /v1/responses; probing it always returns
// "404 page not found" and only adds noise to error messages. Skip it
// entirely for that provider. See issue #1601.
const probes = options.skipResponsesProbe
? [chatCompletionsProbe]
: [responsesProbe, chatCompletionsProbe];

const failures = [];
for (const probe of probes) {
const result = probe.execute();
if (result.ok) {
// Streaming event validation — catch backends like SGLang that return
// valid non-streaming responses but emit incomplete SSE events in
// streaming mode. Only run for /responses probes on custom endpoints
// where probeStreaming was requested.
if (probe.api === "openai-responses" && options.probeStreaming === true) {
const streamResult = runStreamingEventProbe([
"-sS",
...getValidationProbeCurlArgs(),
"-H",
"Content-Type: application/json",
...authHeader,
"-d",
JSON.stringify({
model,
input: "Reply with exactly: OK",
stream: true,
}),
appendKey("/responses"),
]);
if (!streamResult.ok && streamResult.missingEvents.length > 0) {
// Backend responds but lacks required streaming events — fall back
// to /chat/completions silently.
console.log(` ℹ ${streamResult.message}`);
failures.push({
name: probe.name + " (streaming)",
httpStatus: 0,
curlStatus: 0,
message: streamResult.message,
body: "",
});
continue;
}
if (!streamResult.ok) {
// Transport or execution failure — surface as a hard error instead
// of silently switching APIs.
return {
ok: false,
message: `${probe.name} (streaming): ${streamResult.message}`,
failures: [
{
name: probe.name + " (streaming)",
httpStatus: 0,
curlStatus: 0,
message: streamResult.message,
body: "",
},
],
};
}
}
return { ok: true, api: probe.api, label: probe.name };
}
// Preserve the raw response body alongside the summarized message so the
// NVCF "Function not found for account" detector below can fall back to
// the raw body if summarizeProbeError ever stops surfacing the marker
// through `message`.
failures.push({
name: probe.name,
httpStatus: result.httpStatus,
curlStatus: result.curlStatus,
message: result.message,
body: result.body,
});
}

// Single retry with doubled timeouts on timeout/connection failure.
// WSL2's virtualized network stack can cause the initial probe to time out
// before the TLS handshake completes. See issue #987.
const isTimeoutOrConnFailure = (cs) => cs === 28 || cs === 6 || cs === 7;
let retriedAfterTimeout = false;
if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) {
retriedAfterTimeout = true;
const baseArgs = getValidationProbeCurlArgs();
const doubledArgs = baseArgs.map((arg) =>
/^\d+$/.test(arg) ? String(Number(arg) * 2) : arg,
);
const retryResult = runCurlProbe([
"-sS",
...doubledArgs,
"-H",
"Content-Type: application/json",
...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []),
"-d",
JSON.stringify({
model,
messages: [{ role: "user", content: "Reply with exactly: OK" }],
}),
`${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`,
Comment on lines +272 to +289

@coderabbitai coderabbitai Bot Apr 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve authMode in the timeout retry path.

The retry always switches to Authorization: Bearer ... and a plain /chat/completions URL. For endpoints validated with authMode: "query-param", the initial probe can time out, then the retry will fail for the wrong reason because it drops the query-param auth scheme.

Suggested fix
   if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) {
     retriedAfterTimeout = true;
     const baseArgs = getValidationProbeCurlArgs();
     const doubledArgs = baseArgs.map((arg) =>
       /^\d+$/.test(arg) ? String(Number(arg) * 2) : arg,
     );
+    const retryAuthHeader = !useQueryParam && normalizedKey
+      ? ["-H", `Authorization: Bearer ${normalizedKey}`]
+      : [];
+    const retryUrl =
+      useQueryParam && normalizedKey
+        ? `${baseUrl}/chat/completions?key=${encodeURIComponent(normalizedKey)}`
+        : `${baseUrl}/chat/completions`;
     const retryResult = runCurlProbe([
       "-sS",
       ...doubledArgs,
       "-H",
       "Content-Type: application/json",
-      ...(apiKey ? ["-H", `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`] : []),
+      ...retryAuthHeader,
       "-d",
       JSON.stringify({
         model,
         messages: [{ role: "user", content: "Reply with exactly: OK" }],
       }),
-      `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`,
+      retryUrl,
     ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard-inference-probes.ts` around lines 272 - 289, The retry path
currently always adds an Authorization header and targets "/chat/completions",
losing the original authMode and causing query‑param auth to fail; update the
retry in the isTimeoutOrConnFailure branch (where retriedAfterTimeout,
getValidationProbeCurlArgs, runCurlProbe, apiKey, normalizeCredentialValue, and
endpointUrl are used) to preserve authMode by: when authMode === "query-param"
append the normalized apiKey as the same query parameter used by the initial
probe to the retry URL (instead of adding an Authorization header), and
otherwise keep adding the Authorization: Bearer header; also reuse the same base
endpointUrl construction (String(endpointUrl).replace(/\/+$/, "")) rather than
hardcoding a different path so the retry targets the same route as the original
probe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing behavior, moved as-is. The authMode retry path works today because getProbeAuthMode now returns undefined for all providers (changed on main before this PR). Agree it could be more defensive — follow-up material.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyaunches, understood — thanks for the context. Since getProbeAuthMode unconditionally returns undefined as of main, authMode is never "query-param" in practice, so the retry path is safe as-is. Happy to treat the hardening as follow-up.

Would you like me to open a follow-up GitHub issue to track making the retry path preserve authMode defensively (in case getProbeAuthMode ever returns a non-undefined value again)?

🐰 noted and filed away for later!


🧠 Learnings used
Learnt from: ericksoa
Repo: NVIDIA/NemoClaw PR: 1967
File: test/e2e/test-token-rotation.sh:0-0
Timestamp: 2026-04-16T18:34:40.564Z
Learning: In `test/e2e/test-token-rotation.sh` (NemoClaw E2E Bash tests), the token-rotation E2E test intentionally uses fake/invalid Telegram bot tokens so no real Telegram accounts are required in CI. Because fake tokens always return HTTP 401 from `api.telegram.org/bot.../getMe`, in-sandbox `getMe` HTTP status checks cannot distinguish old vs new credentials and are therefore intentionally omitted. Rotation correctness is instead validated via control-flow assertions (grepping onboard output for "credential(s) rotated" and "Rebuilding sandbox"). Live L7 proxy verification with real tokens is a tracked follow-up in issue `#1903`.

]);
if (retryResult.ok) {
return { ok: true, api: "openai-completions", label: "Chat Completions API" };
}
}

// Detect the NVCF "Function not found for account" error and reframe it
// with an actionable next step instead of dumping the raw NVCF body.
// See issue #1601 (Bug 2).
const accountFailure = failures.find(
(failure) =>
isNvcfFunctionNotFoundForAccount(failure.message) ||
isNvcfFunctionNotFoundForAccount(failure.body),
);
if (accountFailure) {
return {
ok: false,
message: nvcfFunctionNotFoundMessage(model),
failures,
};
}

const baseMessage = failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | ");
const wslHint =
isWsl() && retriedAfterTimeout
? " · WSL2 detected \u2014 network verification may be slower than expected. " +
"Run `nemoclaw onboard` with the `--skip-verify` flag if this endpoint is known to be reachable."
: "";
return {
ok: false,
message: baseMessage + wslHint,
failures,
};
}
Comment on lines +135 to +323

@coderabbitai coderabbitai Bot Apr 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Please split probeOpenAiLikeEndpoint instead of disabling the complexity check.

The new module carries forward a very large decision tree and suppresses the repo’s complexity gate with eslint-disable-next-line complexity. This is exactly the kind of logic that becomes brittle during extractions like this; extracting the request builders, streaming fallback, retry path, and failure summarization into helpers would keep the public behavior intact without bypassing the rule.

As per coding guidelines, {src,nemoclaw/src,scripts}/**/*.{js,ts,tsx}: TypeScript files must maintain cyclomatic complexity limit of 20, ratcheting down to 15. Enforced by ESLint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard-inference-probes.ts` around lines 135 - 323,
probeOpenAiLikeEndpoint is too complex and the eslint complexity suppression
must be removed; refactor by extracting smaller helpers: (1) buildProbeRequests
that returns responsesProbe and chatCompletionsProbe (move the
auth/header/appendKey logic and JSON bodies out of the main function), (2)
handleStreamingFallback that encapsulates the runStreamingEventProbe logic for
"openai-responses" and returns the streamResult handling, (3)
executeRetryWithDoubledTimeouts encapsulating the retry logic (currently using
isTimeoutOrConnFailure, getValidationProbeCurlArgs, normalizeCredentialValue and
runCurlProbe), and (4) summarizeFailures that builds baseMessage/wslHint and
detects NVCF using isNvcfFunctionNotFoundForAccount and
nvcfFunctionNotFoundMessage; update probeOpenAiLikeEndpoint to orchestrate these
helpers, remove the eslint-disable-next-line complexity, and keep existing
behavior for responsesProbe, chatCompletionsProbe, runCurlProbe,
runStreamingEventProbe, and failure handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid suggestion but out of scope for this PR. This is a pure extraction — code was moved as-is from onboard.ts with no behavior changes. Complexity reduction in the extracted modules is good follow-up material but should be a separate PR to keep this one reviewable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyaunches, understood — keeping extraction PRs behavior-preserving and deferring quality improvements is a sound approach. Would you like me to open a follow-up issue to track the probeOpenAiLikeEndpoint complexity refactor (extracting buildProbeRequests, handleStreamingFallback, executeRetryWithDoubledTimeouts, and summarizeFailures, and removing the eslint-disable-next-line complexity suppression)?


✏️ Learnings added
Learnt from: jyaunches
Repo: NVIDIA/NemoClaw PR: 2495
File: src/lib/onboard-inference-probes.ts:135-323
Timestamp: 2026-04-27T13:06:57.456Z
Learning: In the NemoClaw repository, PRs that extract code from `src/lib/onboard.ts` into new modules (e.g., `onboard-providers.ts`, `onboard-ollama-proxy.ts`, `onboard-inference-probes.ts`) are intentionally behavior-preserving with no refactoring, so complexity or style improvements in the extracted modules should be deferred to separate follow-up PRs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


// ── Anthropic probe ──────────────────────────────────────────────

function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
const result = runCurlProbe([
"-sS",
...getCurlTimingArgs(),
Comment on lines +327 to +330

@coderabbitai coderabbitai Bot Apr 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the validation timeout helper for Anthropic probes too.

This path falls back to getCurlTimingArgs(), which the comment above explicitly says is the looser default budget. A broken Anthropic-compatible endpoint can therefore stall onboarding much longer than OpenAI-like validation.

Suggested fix
 function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
   const result = runCurlProbe([
     "-sS",
-    ...getCurlTimingArgs(),
+    ...getValidationProbeCurlArgs(),
     "-H",
     `x-api-key: ${normalizeCredentialValue(apiKey)}`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
const result = runCurlProbe([
"-sS",
...getCurlTimingArgs(),
function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
const result = runCurlProbe([
"-sS",
...getValidationProbeCurlArgs(),
"-H",
`x-api-key: ${normalizeCredentialValue(apiKey)}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/onboard-inference-probes.ts` around lines 327 - 330, The Anthropic
probe (probeAnthropicEndpoint) is using the loose default getCurlTimingArgs()
and should use the validation-timeout helper used by the OpenAI probes; update
the runCurlProbe call inside probeAnthropicEndpoint to replace
getCurlTimingArgs() with the validation timeout helper function used elsewhere
for API validation (i.e., the same helper the OpenAI probe uses) so Anthropic
endpoints honor the stricter validation budget.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing behavior, moved as-is. Good catch on the timeout inconsistency — but not introduced by this extraction. Follow-up material.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyaunches, understood — thanks for the context. Since this is a valid inconsistency worth tracking, would you like me to open a follow-up GitHub issue to capture the fix (replacing getCurlTimingArgs() with getValidationProbeCurlArgs() in probeAnthropicEndpoint) so it doesn't get lost?


🧠 Learnings used
Learnt from: jyaunches
Repo: NVIDIA/NemoClaw PR: 1886
File: src/lib/local-inference.ts:289-303
Timestamp: 2026-04-14T22:22:08.976Z
Learning: In `src/lib/local-inference.ts`, `getOllamaWarmupCommand()` intentionally returns `["bash", "-c", "nohup curl ... &"]` — a shell-wrapped argv array — because backgrounding (`nohup ... &`) and output redirection (`>/dev/null 2>&1`) require a shell and have no pure synchronous argv equivalent. The payload is safe: the model name is double-escaped via `JSON.stringify` then `shellQuote` (single-quoted). Switching to `spawn({ detached: true })` would change the caller contract to async and is out of scope. This is the one deliberate `bash -c` exception in the argv refactor (PR `#1886`), and the code has an explanatory comment documenting it.

Learnt from: jyaunches
Repo: NVIDIA/NemoClaw PR: 1886
File: test/onboard-selection.test.ts:137-141
Timestamp: 2026-04-15T14:16:36.856Z
Learning: In `test/onboard-selection.test.ts` (NVIDIA/NemoClaw), the `runner.runCapture` (and `runner.run`) test stubs intentionally normalize commands with `const cmd = Array.isArray(command) ? command.join(" ") : command` because `onboard.ts` still sends legacy shell strings (28 `run()` + 10 `runCapture()` calls not yet migrated to argv), while `local-inference.ts` already sends argv arrays. This dual-form normalization is a deliberate, time-bounded shim for the migration period. Once `#1889` converts `onboard.ts` to argv, these stubs should be updated to assert `Array.isArray` and use structural per-argument checks instead. Do not flag the `join(" ")` normalization as weakening test coverage during this migration window.

"-H",
`x-api-key: ${normalizeCredentialValue(apiKey)}`,
"-H",
"anthropic-version: 2023-06-01",
"-H",
"content-type: application/json",
"-d",
JSON.stringify({
model,
max_tokens: 16,
messages: [{ role: "user", content: "Reply with exactly: OK" }],
}),
`${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`,
]);
if (result.ok) {
return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" };
}
return {
ok: false,
message: result.message,
failures: [
{
name: "Anthropic Messages API",
httpStatus: result.httpStatus,
curlStatus: result.curlStatus,
message: result.message,
},
],
};
}

module.exports = {
parseJsonObject,
hasResponsesToolCall,
shouldRequireResponsesToolCalling,
getProbeAuthMode,
getValidationProbeCurlArgs,
probeResponsesToolCalling,
probeOpenAiLikeEndpoint,
probeAnthropicEndpoint,
};
Loading
Loading