diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index cc1a798df67..deacf8077c8 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -26,6 +26,17 @@ const { resolveProviderCredential, } = require("../credentials/store"); const { isWsl } = require("../platform"); + +/** + * Guidance for a WSL2 host whose endpoint verification keeps timing out. + * + * Exported so the onboarding failure path can print the same wording it is + * appended to `message` with. That path prints failure summaries rather than + * the raw probe message, which can carry provider response bodies (#10413). + */ +export const WSL_SLOW_VERIFICATION_ADVISORY = + "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."; const httpProbe = require("../adapters/http/probe"); const authConfigModule = require("../adapters/http/auth-config"); const openrouter = require("./openrouter"); @@ -1059,14 +1070,16 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { const baseMessage = failures .map((failure) => `${failure.name}: ${failure.message}`) .join(" | "); - const wslHint = - isWsl({ isWsl: options.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." + const wslHint = isWsl({ isWsl: options.isWsl }) && retriedAfterTimeout + ? " · " + WSL_SLOW_VERIFICATION_ADVISORY : ""; return { ok: false, message: baseMessage + wslHint, + // Callers print failure summaries rather than `message`, because a raw + // probe message can carry provider response bodies. Carry the curated + // advisory beside it so the guidance survives that boundary (#10413). + ...(wslHint ? { advisory: WSL_SLOW_VERIFICATION_ADVISORY } : {}), failures, }; } catch (error) { diff --git a/src/lib/inference/probe-http-helpers.test.ts b/src/lib/inference/probe-http-helpers.test.ts index 9d4e4221539..d0386debeb9 100644 --- a/src/lib/inference/probe-http-helpers.test.ts +++ b/src/lib/inference/probe-http-helpers.test.ts @@ -18,14 +18,16 @@ afterEach(() => { describe("validation probe curl timing helpers", () => { it("derives a tighter fast-network profile from calibration latency", () => { expect( - buildValidationProbeTimingProfile({ calibration: { ok: true, durationMs: 180 } }), + buildValidationProbeTimingProfile({ isWsl: false, calibration: { ok: true, durationMs: 180 } }), ).toEqual({ connectTimeoutSeconds: 5, maxTimeSeconds: 15, observedMs: 180, source: "calibrated", }); - expect(getValidationProbeCurlArgs({ calibration: { ok: true, durationMs: 180 } })).toEqual([ + expect( + getValidationProbeCurlArgs({ isWsl: false, calibration: { ok: true, durationMs: 180 } }), + ).toEqual([ "--connect-timeout", "5", "--max-time", @@ -35,7 +37,36 @@ describe("validation probe curl timing helpers", () => { it("derives a slower non-WSL profile from calibration latency", () => { expect( - buildValidationProbeTimingProfile({ calibration: { ok: true, durationMs: 6_400 } }), + buildValidationProbeTimingProfile({ isWsl: false, calibration: { ok: true, durationMs: 6_400 } }), + ).toEqual({ + connectTimeoutSeconds: 28, + maxTimeSeconds: 42, + observedMs: 6400, + source: "calibrated", + }); + }); + + it("keeps the WSL floor when calibration samples a fast endpoint (#10413)", () => { + // Calibration times a cheap `GET /models` and scales that one sample up for + // the far heavier chat-completions POST. On WSL2 the sample can return in + // milliseconds while the POST needs tens of seconds, so the calibrated + // budget must not fall below the floor the uncalibrated branch applies. + expect( + buildValidationProbeTimingProfile({ isWsl: true, calibration: { ok: true, durationMs: 180 } }), + ).toEqual({ + connectTimeoutSeconds: 20, + maxTimeSeconds: 30, + observedMs: 180, + source: "calibrated", + }); + }); + + it("lets calibration raise the budget above the WSL floor (#10413)", () => { + expect( + buildValidationProbeTimingProfile({ + isWsl: true, + calibration: { ok: true, durationMs: 6_400 }, + }), ).toEqual({ connectTimeoutSeconds: 28, maxTimeSeconds: 42, diff --git a/src/lib/inference/probe-http-helpers.ts b/src/lib/inference/probe-http-helpers.ts index e72917644c5..c9410aad274 100644 --- a/src/lib/inference/probe-http-helpers.ts +++ b/src/lib/inference/probe-http-helpers.ts @@ -82,9 +82,20 @@ export function buildValidationProbeTimingProfile( CALIBRATED_MAX_TIME_MIN_SECONDS, CALIBRATED_MAX_TIME_MAX_SECONDS, ); + // Calibration samples a cheap endpoint (`GET /models`) and scales that one + // observation up for the far heavier chat-completions POST. On WSL2 the + // virtualized network stack makes that scaling optimistic: the sample can + // return in milliseconds while the POST needs tens of seconds. Keep the + // floor the uncalibrated branch applies, so calibration can raise a slow + // host's budget but never lower it below what WSL2 already needs (#10413). + const wslFloor = isWsl(opts); return { - connectTimeoutSeconds, - maxTimeSeconds, + connectTimeoutSeconds: wslFloor + ? Math.max(connectTimeoutSeconds, WSL_VALIDATION_TIMING.connectTimeoutSeconds) + : connectTimeoutSeconds, + maxTimeSeconds: wslFloor + ? Math.max(maxTimeSeconds, WSL_VALIDATION_TIMING.maxTimeSeconds) + : maxTimeSeconds, observedMs: Math.max(0, Math.round(opts.calibration.durationMs)), source: "calibrated", }; diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 63f5f89dee8..7de71c89718 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { WSL_SLOW_VERIFICATION_ADVISORY } from "../inference/onboard-probes"; import { useOpenAiValidationTestServers } from "../inference/openai-validation-session.test-helpers"; import { OnboardInferenceCapabilityCache } from "./inference-capability-cache"; import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation"; @@ -338,6 +339,90 @@ describe("inference selection validation", () => { } }); + it("prints transport guidance and the WSL advisory before the non-interactive abort (#10413)", async () => { + // A non-interactive run exits before the recovery prompt, which is where + // this guidance normally reaches an operator. Without it the terminal ends + // at a bare "curl exit 28" and names no next step. + const originalExitCode = process.exitCode; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const promptValidationRecovery = vi.fn(async () => "selection" as const); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => true, + agentProductName: () => "OpenClaw", + getCredential: () => "nvapi-test-key-12345", + probeOpenAiLikeEndpoint: () => ({ + ok: false, + advisory: WSL_SLOW_VERIFICATION_ADVISORY, + failures: [ + { + name: "Chat Completions API", + curlStatus: 28, + message: "curl failed (exit 28)", + }, + ], + }), + teardownOrphanManagedGatewayOnAbort: () => true, + promptValidationRecovery, + }); + + try { + await expect( + helpers.validateOpenAiLikeSelection( + "NVIDIA Endpoints", + "https://integrate.api.nvidia.com/v1", + "meta/llama-3.3-70b-instruct", + "NVIDIA_INFERENCE_API_KEY", + ), + ).rejects.toMatchObject(resumableValidationExit); + expect(promptValidationRecovery).not.toHaveBeenCalled(); + expect(error.mock.calls.map((args) => args.join(" "))).toEqual([ + " NVIDIA Endpoints endpoint validation failed.", + " Validation probe summary: Chat Completions API: curl exit 28.", + " Validation details were omitted to avoid exposing credentials.", + " Validation timed out before the provider replied. Retry, or check network/proxy health.", + ` ${WSL_SLOW_VERIFICATION_ADVISORY}`, + ]); + } finally { + process.exitCode = originalExitCode; + error.mockRestore(); + } + }); + + it("shows the WSL advisory on the interactive recovery path too (#10413)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const promptValidationRecovery = vi.fn(async () => "selection" as const); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "nvapi-test-key-12345", + probeOpenAiLikeEndpoint: () => ({ + ok: false, + advisory: WSL_SLOW_VERIFICATION_ADVISORY, + failures: [{ name: "Chat Completions API", curlStatus: 28 }], + }), + promptValidationRecovery, + }); + + try { + await helpers.validateOpenAiLikeSelection( + "NVIDIA Endpoints", + "https://integrate.api.nvidia.com/v1", + "meta/llama-3.3-70b-instruct", + "NVIDIA_INFERENCE_API_KEY", + ); + // The prompt owns transport guidance here, so only the advisory is added. + expect(error.mock.calls.map((args) => args.join(" "))).toEqual([ + " NVIDIA Endpoints endpoint validation failed.", + " Validation probe summary: Chat Completions API: curl exit 28.", + " Validation details were omitted to avoid exposing credentials.", + ` ${WSL_SLOW_VERIFICATION_ADVISORY}`, + ]); + expect(promptValidationRecovery).toHaveBeenCalledOnce(); + } finally { + error.mockRestore(); + } + }); + it("fails reasoning-mode validation when Chat Completions fails (#3279)", async () => { vi.stubEnv("NEMOCLAW_REASONING", "yes"); const probeOpenAiLikeEndpoint = vi.fn(() => ({ diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index d3c1e5cef1d..47cff1abe2c 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -40,7 +40,11 @@ import { parseTrustedPrivateInferenceHostsFromEnv, } from "../inference/endpoint-ssrf-preflight"; import { shouldForceCompletionsApi } from "../validation"; -import { getProbeRecovery } from "../validation-recovery"; +import { + getProbeRecovery, + getTransportRecoveryMessage, + type ProbeLike, +} from "../validation-recovery"; import { summarizeProbeForDisplay } from "./probe-diagnostics"; import { normalizeReasoningFlag } from "./reasoning-mode"; import { OnboardDeferredExitError } from "./session-bootstrap"; @@ -188,11 +192,27 @@ export function createInferenceSelectionValidationHelpers( function printValidationFailure( label: string, - probe?: { failures?: unknown[]; message?: unknown }, + probe?: { failures?: unknown[]; message?: unknown; advisory?: unknown }, ): void { console.error(` ${label} endpoint validation failed.`); if (probe) console.error(` Validation probe summary: ${summarizeProbeForDisplay(probe)}.`); console.error(" Validation details were omitted to avoid exposing credentials."); + if (!probe) return; + // An interactive run reaches transport guidance through the recovery + // prompt. A non-interactive run exits at the next statement, so without + // this the operator is left with a bare "curl exit 28" and no next step. + if (deps.isNonInteractive()) { + const recovery = getProbeRecovery(probe as ProbeLike); + if (recovery.kind === "transport" && "failure" in recovery) { + console.error(getTransportRecoveryMessage(recovery.failure)); + } + } + // The probe's curated advisory rides on `message`, which no caller prints + // because it can carry provider response bodies. Neither path shows it + // today, so print it here for both (#10413). + if (typeof probe.advisory === "string" && probe.advisory) { + console.error(` ${probe.advisory}`); + } } function printGeminiRuntimeNotFoundGuidance(