diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index 1b418358a4f..6b3971b2134 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -93,7 +93,7 @@ "name": "Other OpenAI-compatible endpoint", "status": "caveated", "endpoint_type": "Custom OpenAI-compatible", - "notes": "Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3673`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints." + "notes": "Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3661`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints." }, { "name": "Anthropic", @@ -129,7 +129,7 @@ "name": "Local NVIDIA NIM", "status": "experimental", "endpoint_type": "Local OpenAI-compatible", - "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." + "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1632`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." }, { "name": "Local vLLM (already running)", @@ -218,7 +218,7 @@ { "name": "Podman / other container runtimes", "status": "unsupported", - "notes": "Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard.ts:1611` prints the rejection; `src/lib/onboard/preflight.ts:586` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed)." + "notes": "Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard.ts:1599` prints the rejection; `src/lib/onboard/preflight.ts:586` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed)." }, { "name": "Intel Mac (macOS x86_64)", @@ -248,7 +248,7 @@ { "name": "Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal)", "status": "unsupported", - "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1586`, called at `:1651`). NemoClaw does not install non-NVIDIA accelerator drivers." + "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1632`). NemoClaw does not install non-NVIDIA accelerator drivers." }, { "name": "Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses", diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index 7619769f722..77936673aa9 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -43,13 +43,13 @@ NemoClaw uses provider-specific local tokens for those routes, and rebuilds of l |----------|--------|---------------|-------| | NVIDIA Endpoints | Tested | OpenAI-compatible | Hosted models on integrate.api.nvidia.com | | OpenAI | Tested | Native OpenAI-compatible | Uses OpenAI model IDs | -| Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3673`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | +| Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3661`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | | Anthropic | Tested | Native Anthropic | Uses anthropic-messages | | Other Anthropic-compatible endpoint | Tested with limitations | Custom Anthropic-compatible | Adapter path validated with AWS Bedrock (`src/lib/onboard/bedrock-runtime.ts`). Behavior on other Anthropic-compatible proxies and gateways may vary; this row claims the adapter, not the universe of compatible endpoints. | | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1632`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status:end */} @@ -237,11 +237,18 @@ Route, configuration, and authentication failures still fail immediately. ### Non-Interactive Setup Set the following environment variables for scripted or CI/CD deployments. +Set `NEMOCLAW_REASONING=true` when the compatible endpoint serves a reasoning-only model. + + + Reasoning mode validates only `/v1/chat/completions`; it does not verify tool calling or + streaming. Enable it only when the endpoint supports the capabilities your agent needs. + ```bash NEMOCLAW_PROVIDER=custom \ NEMOCLAW_ENDPOINT_URL=http://localhost:8000/v1 \ NEMOCLAW_MODEL=meta-llama/Llama-3.1-8B-Instruct \ + NEMOCLAW_REASONING=true \ COMPATIBLE_API_KEY=dummy \ $$nemoclaw onboard --non-interactive ``` @@ -251,6 +258,7 @@ NEMOCLAW_PROVIDER=custom \ | `NEMOCLAW_PROVIDER` | Set to `custom` for an OpenAI-compatible endpoint. | | `NEMOCLAW_ENDPOINT_URL` | Base URL of the local server. | | `NEMOCLAW_MODEL` | Model ID as reported by the server. | +| `NEMOCLAW_REASONING` | Set to `true` for a reasoning-only compatible endpoint model. Onboarding accepts `true`, `1`, `yes`, and `y` case-insensitively, then stores `true`; the default is `false`. | | `COMPATIBLE_API_KEY` | API key for the endpoint. Use any non-empty value if authentication is not required. | ### Selecting the API Path diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index f980dc0c6d5..279da8e1967 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -95,13 +95,13 @@ NemoClaw routes inference through the OpenShell gateway. Each row below is a pro |----------|--------|---------------|-------| | NVIDIA Endpoints | Tested | OpenAI-compatible | Hosted models on integrate.api.nvidia.com | | OpenAI | Tested | Native OpenAI-compatible | Uses OpenAI model IDs | -| Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3673`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | +| Other OpenAI-compatible endpoint | Tested with limitations | Custom OpenAI-compatible | Adapter path validated against OpenRouter as the `compatible-endpoint` provider with `openrouter/auto` (see `src/lib/inference/config.test.ts:119`); the onboarding prompt that surfaces OpenRouter as the worked example is at `src/lib/onboard.ts:3661`. Behavior on other OpenAI-compatible proxies, gateways, and self-hosted implementations may vary; this row claims the adapter, not the universe of compatible endpoints. | | Anthropic | Tested | Native Anthropic | Uses anthropic-messages | | Other Anthropic-compatible endpoint | Tested with limitations | Custom Anthropic-compatible | Adapter path validated with AWS Bedrock (`src/lib/onboard/bedrock-runtime.ts`). Behavior on other Anthropic-compatible proxies and gateways may vary; this row claims the adapter, not the universe of compatible endpoints. | | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1632`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status-full:end */} @@ -160,13 +160,13 @@ They are listed here so launch material, sales conversations, and support triage {/* out-of-scope:begin */} | Item | Status | Why | |------|--------|-----| -| Podman / other container runtimes | Unsupported | Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard.ts:1611` prints the rejection; `src/lib/onboard/preflight.ts:586` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed). | +| Podman / other container runtimes | Unsupported | Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard.ts:1599` prints the rejection; `src/lib/onboard/preflight.ts:586` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed). | | Intel Mac (macOS x86_64) | Unsupported | OpenShell does not publish macOS x86_64 standalone gateway assets. Install hard-fails on x86_64 macOS (`scripts/install-openshell.sh:315`). See issue #954 (closed). | | Non-Ubuntu/Debian Linux distros | Unsupported | Installer assumes `apt-get`. Fedora/Rocky/Alma/Arch/NixOS are not validated and the installer's package-manager probes do not cover them. See open issue #899 (Fedora hang). | | Native Kubernetes or OpenShift deployments | Unsupported | NemoClaw runs the sandbox as a Docker container, not a Kubernetes pod. The default Docker-driver topology does not embed k3s. Operator-managed K8s/OpenShift deployments are out of scope; see issue #407 (community OpenShift through agent-sandbox CRD). | | Air-gapped / offline installs | Unsupported | Onboard assumes network reachability for package fetches, container pulls, and provider validation. See open issues #4872 and #2218 (production-deployment epic covering air-gapped support, China network guidance, multi-host topology). | | Windows-on-ARM GPU passthrough | Unsupported | Windows-on-ARM CPU paths run under WSL2 'tested with limitations', but GPU passthrough on WOA is denylisted (`src/lib/onboard/wsl-docker-desktop-gpu.ts:188`, `src/lib/inference/gpu-trust.test.ts:70`). See closed issue #4565. | -| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1586`, called at `:1651`). NemoClaw does not install non-NVIDIA accelerator drivers. | +| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1632`). NemoClaw does not install non-NVIDIA accelerator drivers. | | Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses | Unsupported | LangChain Deep Agents Code is the only integrated LangChain-family harness (see the Agents section above; status `Experimental`). Other LangChain harnesses, AutoGen, CrewAI, and any agent runtime not listed in the Agents table are not integrated. Bringing more harnesses is tracked as a research epic (see open issue #4861) but is not on the current roadmap. | | Multi-user host sharing | Unsupported | Sandboxes are scoped to a single host user. NemoClaw treats multi-user hosts as a risk and warns at onboard; see `docs/security/openclaw-controls.mdx` Multi-user detection. | | Hosted SaaS / managed NemoClaw | Unsupported | There is no managed offering. Supported deployment paths are Local CLI onboard, Remote GPU with Brev CLI, and Brev web UI. | diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f11ab5d0593..442b83c3eea 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -9,6 +9,8 @@ const { envInt, LOCAL_INFERENCE_TIMEOUT_SECS, }: typeof import("./onboard/env") = require("./onboard/env"); +type ProviderSelectionResult = + import("./onboard/machine/handlers/provider-inference").ProviderSelectionResult; const { agentProductName, cliDisplayName, @@ -27,10 +29,9 @@ const { createRemoteModelValidator, requireProviderChoice, }: typeof import("./onboard/setup-nim-selection") = require("./onboard/setup-nim-selection"); -const { - createSetupNimOllamaHandlers, -}: typeof import("./onboard/setup-nim-ollama") = require("./onboard/setup-nim-ollama"); +const setupNimOllama: typeof import("./onboard/setup-nim-ollama") = require("./onboard/setup-nim-ollama"); const inferenceInputCapability = require("./onboard/inference-input-capability"); +const reasoningMode: typeof import("./onboard/reasoning-mode") = require("./onboard/reasoning-mode"); const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { abortNonInteractive, @@ -1047,6 +1048,7 @@ const { validateSelectedRemoteModel } = createRemoteModelValidator({ shouldRequireResponsesToolCalling, shouldSkipResponsesProbe, getProbeAuthMode, + configureCompatibleEndpointReasoning: reasoningMode.configureCompatibleEndpointReasoning, }); const { promptCloudModel, promptRemoteModel, promptInputModel } = modelPrompts; @@ -1068,7 +1070,7 @@ const { handleWindowsHostOllamaSelection, handleRunningOllamaSelection, handleInstallOllamaSelection, -} = createSetupNimOllamaHandlers({ +} = setupNimOllama.createSetupNimOllamaHandlers({ OLLAMA_PORT, OLLAMA_PROXY_PORT, process, @@ -3926,18 +3928,7 @@ async function setupNim( sandboxName: string | null = null, agent: AgentDefinition | null = null, recoverProvider = true, -): Promise<{ - model: string | null; - provider: string; - endpointUrl: string | null; - credentialEnv: string | null; - hermesAuthMethod: HermesAuthMethod | null; - hermesToolGateways: string[]; - preferredInferenceApi: string | null; - nimContainer: string | null; - allowToolsIncompatible: boolean; - skipHostInferenceSmoke: boolean; -}> { +): Promise { step(3, 8, "Configuring inference provider"); let model: string | typeof BACK_TO_SELECTION | null = null; @@ -3948,6 +3939,7 @@ async function setupNim( let hermesAuthMethod: HermesAuthMethod | null = null; let hermesToolGateways: string[] = []; let preferredInferenceApi: string | null = null; + let compatibleEndpointReasoning: string | null = null; let allowToolsIncompatible = false; let skipHostInferenceSmoke = false; @@ -4080,6 +4072,7 @@ async function setupNim( hermesAuthMethod, hermesToolGateways, preferredInferenceApi, + compatibleEndpointReasoning, nimContainer, allowToolsIncompatible, }; @@ -4097,6 +4090,7 @@ async function setupNim( preferredInferenceApi, allowToolsIncompatible, } = state); + compatibleEndpointReasoning = state.compatibleEndpointReasoning ?? null; skipHostInferenceSmoke = state.skipHostInferenceSmoke === true; if (result === "retry-selection") continue selectionLoop; break; @@ -4296,6 +4290,8 @@ async function setupNim( } } + if (provider !== "compatible-endpoint") + compatibleEndpointReasoning = reasoningMode.clearCompatibleEndpointReasoning(); const selectedModel = isBackToSelection(model) ? null : model; await inferenceInputCapability.maybePromptForInferenceInputCapability(selectedModel, { isNonInteractive, @@ -4309,6 +4305,7 @@ async function setupNim( hermesAuthMethod, hermesToolGateways, preferredInferenceApi, + compatibleEndpointReasoning, nimContainer, allowToolsIncompatible, skipHostInferenceSmoke, @@ -4888,6 +4885,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { hermesAuthMethod: normalizeHermesAuthMethod(session?.hermesAuthMethod), hermesToolGateways: normalizeHermesToolGatewaySelections(session?.hermesToolGateways), preferredInferenceApi: session?.preferredInferenceApi || null, + compatibleEndpointReasoning: session?.compatibleEndpointReasoning || null, nimContainer: session?.nimContainer || null, webSearchConfig: session?.webSearchConfig || null, webSearchSupported: false, @@ -5028,6 +5026,8 @@ async function onboard(opts: OnboardOptions = {}): Promise { recordStateSkipped, recordRepairEvent, hydrateCredentialEnv, + configureCompatibleEndpointReasoning: reasoningMode.configureCompatibleEndpointReasoning, + clearCompatibleEndpointReasoning: reasoningMode.clearCompatibleEndpointReasoning, repairLocalInferenceSystemdOverrideOrExit, isNonInteractive, getOpenshellBinary, diff --git a/src/lib/onboard/compatible-endpoint-smoke.test.ts b/src/lib/onboard/compatible-endpoint-smoke.test.ts index 7ddfbf4ebaa..ac18b7d45ee 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.test.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.test.ts @@ -81,7 +81,7 @@ describe("compatible endpoint sandbox smoke helpers", () => { expect(script).toContain("INFERENCE_SMOKE_OK"); expect(script).toContain("models.providers.inference"); expect(script).toContain("https://inference.local/v1/chat/completions"); - expect(script).toContain("INITIAL_MAX_TOKENS=256"); + expect(script).toContain("INITIAL_MAX_TOKENS=512"); expect(script).toContain("RETRY_MAX_TOKENS=1024"); expect(script).toContain("SMOKE_ATTEMPTS=3"); expect(script).toContain("SMOKE_REQUEST_TIMEOUT_SECONDS=60"); diff --git a/src/lib/onboard/compatible-endpoint-smoke.ts b/src/lib/onboard/compatible-endpoint-smoke.ts index c938242a1a6..a67009ed8e3 100644 --- a/src/lib/onboard/compatible-endpoint-smoke.ts +++ b/src/lib/onboard/compatible-endpoint-smoke.ts @@ -197,6 +197,8 @@ export function verifyCompatibleEndpointSandboxSmoke(options: { * Builds the shell script that runs inside the sandbox to confirm OpenClaw is * routed through NemoClaw's managed inference provider and can receive assistant * content from the compatible endpoint. + * Reasoning-only endpoints may fill 512 tokens in reasoning_content before final content; + * finish_reason=length retries at 1024 until providers offer non-reasoning output. */ export function buildCompatibleEndpointSandboxSmokeScript( model: string, @@ -204,7 +206,7 @@ export function buildCompatibleEndpointSandboxSmokeScript( ): string { const configPath = options.configPath || "/sandbox/.openclaw/openclaw.json"; const inferenceUrl = options.inferenceUrl || `${INFERENCE_ROUTE_URL}/chat/completions`; - const initialMaxTokens = positiveInt(options.initialMaxTokens, 256); + const initialMaxTokens = positiveInt(options.initialMaxTokens, 512); const attempts = positiveInt(options.attempts, COMPATIBLE_ENDPOINT_SMOKE_ATTEMPTS); const retryDelaySeconds = nonNegativeInt( options.retryDelaySeconds, diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 51ca9b3e694..46fcfe3deab 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -45,4 +45,45 @@ describe("inference selection validation", () => { exit.mockRestore(); } }); + + it("fails reasoning-mode validation when Chat Completions fails (#3279)", async () => { + vi.stubEnv("NEMOCLAW_REASONING", "yes"); + const probeOpenAiLikeEndpoint = vi.fn(() => ({ + ok: false, + failures: [{ name: "Chat Completions API", httpStatus: 500 }], + })); + const promptValidationRecovery = vi.fn(async () => "selection" as const); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const helpers = createInferenceSelectionValidationHelpers({ + isNonInteractive: () => false, + agentProductName: () => "OpenClaw", + getCredential: () => "test-key", + probeOpenAiLikeEndpoint, + promptValidationRecovery, + }); + + try { + await expect( + helpers.validateCustomOpenAiLikeSelection( + "Custom endpoint", + "https://compatible.example/v1", + "reasoning-model", + "COMPATIBLE_API_KEY", + ), + ).resolves.toEqual({ ok: false, retry: "selection" }); + expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith( + "https://compatible.example/v1", + "reasoning-model", + "test-key", + { + requireResponsesToolCalling: false, + skipResponsesProbe: true, + probeStreaming: false, + }, + ); + } finally { + error.mockRestore(); + vi.unstubAllEnvs(); + } + }); }); diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index c8b0e8891d7..b65f4d2a90d 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -21,6 +21,7 @@ const { probeAnthropicEndpoint, probeOpenAiLikeEndpoint } = import { shouldForceCompletionsApi } from "../validation"; import { getProbeRecovery } from "../validation-recovery"; import { summarizeProbeForDisplay } from "./probe-diagnostics"; +import { normalizeReasoningFlag } from "./reasoning-mode"; export type EndpointValidationResult = | { ok: true; api: string | null; retry?: undefined } @@ -185,10 +186,13 @@ export function createInferenceSelectionValidationHelpers( helpUrl: string | null = null, ): Promise { const apiKey = resolveCredential(credentialEnv); + const reasoningEnabled = normalizeReasoningFlag(process.env.NEMOCLAW_REASONING) === "true"; + // Reasoning-only compatible endpoints often reject Responses, tool-call, and streaming probes. const probe = runOpenAiLikeProbe(endpointUrl, model, apiKey, { - requireResponsesToolCalling: true, - skipResponsesProbe: shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), - probeStreaming: true, + requireResponsesToolCalling: !reasoningEnabled, + skipResponsesProbe: + reasoningEnabled || shouldForceCompletionsApi(process.env.NEMOCLAW_PREFERRED_API), + probeStreaming: !reasoningEnabled, }); if (probe.ok) { if (probe.note) { diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 9bf53e9b162..20ed7b60d45 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -40,6 +40,7 @@ function context( hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, @@ -91,6 +92,7 @@ function createPhases( hermesAuthMethod: null, hermesToolGateways: ["local"], preferredInferenceApi: "chat", + compatibleEndpointReasoning: null, nimContainer: "nim-test", })), setupInference: vi.fn(async () => ({ ok: true as const })), @@ -107,6 +109,8 @@ function createPhases( recordStateSkipped: vi.fn(async () => createSession()), recordRepairEvent: vi.fn(async () => createSession()), hydrateCredentialEnv: vi.fn(), + configureCompatibleEndpointReasoning: vi.fn(async () => "false" as const), + clearCompatibleEndpointReasoning: vi.fn(() => null), repairLocalInferenceSystemdOverrideOrExit: vi.fn(), isNonInteractive: () => true, getOpenshellBinary: () => "openshell", @@ -231,6 +235,7 @@ describe("core onboard flow phases", () => { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: "chat", + compatibleEndpointReasoning: null, nimContainer: null, })); const [providerPhase] = createPhases({ providerDeps: { setupNim } }); diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index de183ad4f7a..dec13cdddf8 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -69,6 +69,7 @@ export function createCoreOnboardFlowPhases< hermesAuthMethod: context.hermesAuthMethod, hermesToolGateways: context.hermesToolGateways, preferredInferenceApi: context.preferredInferenceApi, + compatibleEndpointReasoning: context.compatibleEndpointReasoning, nimContainer: context.nimContainer, webSearchConfig: context.webSearchConfig, }, @@ -89,6 +90,7 @@ export function createCoreOnboardFlowPhases< hermesAuthMethod: providerInferenceResult.hermesAuthMethod, hermesToolGateways: providerInferenceResult.hermesToolGateways, preferredInferenceApi: providerInferenceResult.preferredInferenceApi, + compatibleEndpointReasoning: providerInferenceResult.compatibleEndpointReasoning, nimContainer: providerInferenceResult.nimContainer, webSearchConfig: providerInferenceResult.webSearchConfig, }), diff --git a/src/lib/onboard/machine/flow-context.test.ts b/src/lib/onboard/machine/flow-context.test.ts index c2cf9d51dd1..1e015a20214 100644 --- a/src/lib/onboard/machine/flow-context.test.ts +++ b/src/lib/onboard/machine/flow-context.test.ts @@ -33,6 +33,7 @@ function baseContext(): OnboardFlowContext { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, }); @@ -133,6 +135,7 @@ describe("onboard flow context helpers", () => { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, }); diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index 4fb20a38fdf..319f7c17bb2 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -21,6 +21,7 @@ export interface OnboardFlowContext { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: true, diff --git a/src/lib/onboard/machine/flow-phases/preflight-gateway.test.ts b/src/lib/onboard/machine/flow-phases/preflight-gateway.test.ts index ed0451c5d39..82cf445bee0 100644 --- a/src/lib/onboard/machine/flow-phases/preflight-gateway.test.ts +++ b/src/lib/onboard/machine/flow-phases/preflight-gateway.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { createSession } from "../../../state/onboard-session"; -import { advanceTo } from "../result"; import type { OnboardFlowContext } from "../flow-context"; +import { advanceTo } from "../result"; import { createGatewayPhase, createPreflightPhase } from "./preflight-gateway"; function context(): OnboardFlowContext { @@ -25,6 +25,7 @@ function context(): OnboardFlowContext hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, diff --git a/src/lib/onboard/machine/flow-phases/provider-sandbox.test.ts b/src/lib/onboard/machine/flow-phases/provider-sandbox.test.ts index ede225a01a3..5b99869b6a0 100644 --- a/src/lib/onboard/machine/flow-phases/provider-sandbox.test.ts +++ b/src/lib/onboard/machine/flow-phases/provider-sandbox.test.ts @@ -27,6 +27,7 @@ function context( hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, @@ -52,6 +53,7 @@ describe("provider/sandbox flow phases", () => { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, }, diff --git a/src/lib/onboard/machine/flow-sequence.test.ts b/src/lib/onboard/machine/flow-sequence.test.ts index c4f45ba5059..663b0951d2f 100644 --- a/src/lib/onboard/machine/flow-sequence.test.ts +++ b/src/lib/onboard/machine/flow-sequence.test.ts @@ -38,6 +38,7 @@ function context(patch: Partial = {}): Context { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, diff --git a/src/lib/onboard/machine/flow-slices.test.ts b/src/lib/onboard/machine/flow-slices.test.ts index aacae323785..cf95370c2d5 100644 --- a/src/lib/onboard/machine/flow-slices.test.ts +++ b/src/lib/onboard/machine/flow-slices.test.ts @@ -12,9 +12,6 @@ import { type SessionUpdates, } from "../../state/onboard-session"; import type { OnboardFlowContext } from "./flow-context"; -import { advanceTo, branchTo, completeOnboardMachine } from "./result"; -import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; -import type { OnboardSequencePhase } from "./sequence-runner"; import { coreOnboardFlowPhases, finalOnboardFlowPhases, @@ -23,6 +20,9 @@ import { runFinalOnboardFlowSequence, runInitialOnboardFlowSequence, } from "./flow-slices"; +import { advanceTo, branchTo, completeOnboardMachine } from "./result"; +import { OnboardRuntime, type OnboardRuntimeDeps } from "./runtime"; +import type { OnboardSequencePhase } from "./sequence-runner"; function cloneSession(session: Session): Session { return normalizeSession(JSON.parse(JSON.stringify(session))) ?? session; @@ -75,6 +75,7 @@ function context(): OnboardFlowContext { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index c31d303d4bb..3b1bc8829f3 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -1,9 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { patchStagedDockerfile } from "../../dockerfile-patch"; +import { clearCompatibleEndpointReasoning } from "../../reasoning-mode"; import { handleProviderInferenceState, type ProviderInferenceStateOptions, @@ -22,6 +28,7 @@ const baseSelection: ProviderSelectionResult = { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, nimContainer: null, }; @@ -76,6 +83,9 @@ function createDeps( recordStateSkipped: calls.recordSkip, recordRepairEvent: calls.repairEvent, hydrateCredentialEnv: calls.hydrate, + configureCompatibleEndpointReasoning: async (value?: string | null) => + value === "true" ? "true" : "false", + clearCompatibleEndpointReasoning: () => null, repairLocalInferenceSystemdOverrideOrExit: calls.repair, isNonInteractive: () => true, getOpenshellBinary: () => "/usr/bin/openshell", @@ -123,6 +133,7 @@ function baseOptions( hermesAuthMethod: session?.hermesAuthMethod ?? null, hermesToolGateways: session?.hermesToolGateways ?? [], preferredInferenceApi: session?.preferredInferenceApi ?? null, + compatibleEndpointReasoning: session?.compatibleEndpointReasoning ?? null, nimContainer: session?.nimContainer ?? null, webSearchConfig: session?.webSearchConfig ?? null, }, @@ -145,10 +156,6 @@ describe("handleProviderInferenceState", () => { expect(calls.startStep).toHaveBeenNthCalledWith(1, "provider_selection"); expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, null, null, true); - expect(calls.complete).toHaveBeenCalledWith( - "provider_selection", - expect.objectContaining({ provider: "nvidia-prod" }), - ); expect(calls.promptName).toHaveBeenCalledWith(null); expect(calls.log).toHaveBeenCalledWith("summary:nvidia-prod/nvidia/test/my-assistant"); expect(calls.startStep).toHaveBeenNthCalledWith(2, "inference", { @@ -171,6 +178,7 @@ describe("handleProviderInferenceState", () => { model: "nvidia/test", provider: "nvidia-prod", preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, }); expect(result.stateResult).toEqual({ type: "transition", @@ -192,6 +200,72 @@ describe("handleProviderInferenceState", () => { ]); }); + describe("compatible endpoint reasoning mode", () => { + it("records reasoning state during provider selection", async () => { + const setupNim = vi.fn(async () => ({ + ...baseSelection, + compatibleEndpointReasoning: "true", + provider: "compatible-endpoint", + credentialEnv: "COMPATIBLE_API_KEY", + })); + const { deps } = createDeps({ setupNim }); + + const result = await handleProviderInferenceState({ + ...baseOptions(deps), + env: { NEMOCLAW_REASONING: "true" }, + }); + + expect(result).toMatchObject({ + compatibleEndpointReasoning: "true", + provider: "compatible-endpoint", + }); + }); + + it("clears stale resumed state before writing a non-compatible artifact", async () => { + vi.stubEnv("NEMOCLAW_REASONING", "true"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reasoning-resume-")); + const dockerfilePath = path.join(tempDir, "Dockerfile"); + fs.writeFileSync(dockerfilePath, "ARG NEMOCLAW_REASONING=false\n"); + const session = createSession({ + provider: "nvidia-prod", + model: "nvidia/test", + compatibleEndpointReasoning: "true", + }); + session.steps.provider_selection.status = "complete"; + const setupInference = vi.fn(async () => { + expect(process.env.NEMOCLAW_REASONING).toBeUndefined(); + patchStagedDockerfile( + dockerfilePath, + "nvidia/test", + "https://chat.example", + "build-1", + "nvidia-prod", + ); + return { ok: true as const }; + }); + const { deps } = createDeps({ + clearCompatibleEndpointReasoning, + setupInference, + isInferenceRouteReady: vi.fn(() => false), + }); + + try { + const result = await handleProviderInferenceState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + }); + + expect(setupInference).toHaveBeenCalledOnce(); + expect(result.compatibleEndpointReasoning).toBeNull(); + expect(fs.readFileSync(dockerfilePath, "utf-8")).toContain("ARG NEMOCLAW_REASONING=false"); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + it("disables recorded provider recovery during fresh provider selection", async () => { const { deps, calls } = createDeps(); diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index c3a7f7d5bf1..a28d01dd1b9 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -16,6 +16,7 @@ export interface ProviderSelectionResult { hermesAuthMethod: string | null; hermesToolGateways: string[]; preferredInferenceApi: string | null; + compatibleEndpointReasoning: string | null; nimContainer: string | null; allowToolsIncompatible?: boolean; skipHostInferenceSmoke?: boolean; @@ -37,6 +38,7 @@ export interface ProviderInferenceStateOptions { hermesAuthMethod: string | null; hermesToolGateways: string[]; preferredInferenceApi: string | null; + compatibleEndpointReasoning: string | null; nimContainer: string | null; webSearchConfig: WebSearchConfig | null; }; @@ -89,6 +91,8 @@ export interface ProviderInferenceStateOptions { }, ): Promise; hydrateCredentialEnv(credentialEnv: string | null): string | null | undefined; + configureCompatibleEndpointReasoning(storedValue?: string | null): Promise<"true" | "false">; + clearCompatibleEndpointReasoning(): null; repairLocalInferenceSystemdOverrideOrExit( provider: string | null, isNonInteractive: () => boolean, @@ -141,6 +145,7 @@ export interface ProviderInferenceStateResult { hermesAuthMethod: string | null; hermesToolGateways: string[]; preferredInferenceApi: string | null; + compatibleEndpointReasoning: string | null; nimContainer: string | null; webSearchConfig: WebSearchConfig | null; session: Session | null; @@ -227,6 +232,7 @@ export async function handleProviderInferenceState({ : null); let hermesToolGateways = initial.hermesToolGateways; let preferredInferenceApi = initial.preferredInferenceApi; + let compatibleEndpointReasoning = initial.compatibleEndpointReasoning; let nimContainer = initial.nimContainer; const webSearchConfig = initial.webSearchConfig; let forceProviderSelection = initialForceProviderSelection; @@ -282,6 +288,10 @@ export async function handleProviderInferenceState({ : " [resume] Refreshing compatible-endpoint inference route for messaging.", ); } + compatibleEndpointReasoning = + provider === "compatible-endpoint" + ? await deps.configureCompatibleEndpointReasoning(compatibleEndpointReasoning) + : deps.clearCompatibleEndpointReasoning(); if (provider === "ollama-local") { const repairMetadata = { repair: "ollama-systemd-loopback" }; await deps.recordRepairEvent("state.repair.started", { @@ -319,6 +329,7 @@ export async function handleProviderInferenceState({ hermesAuthMethod = selection.hermesAuthMethod; hermesToolGateways = selection.hermesToolGateways; preferredInferenceApi = selection.preferredInferenceApi; + compatibleEndpointReasoning = selection.compatibleEndpointReasoning; nimContainer = selection.nimContainer; allowToolsIncompatible = selection.allowToolsIncompatible === true; skipHostInferenceSmoke = selection.skipHostInferenceSmoke === true; @@ -341,6 +352,7 @@ export async function handleProviderInferenceState({ hermesAuthMethod, hermesToolGateways, preferredInferenceApi, + compatibleEndpointReasoning, nimContainer, }), ); @@ -403,6 +415,7 @@ export async function handleProviderInferenceState({ provider, model, hermesAuthMethod, + compatibleEndpointReasoning, nimContainer, hermesToolGateways, }), @@ -443,6 +456,7 @@ export async function handleProviderInferenceState({ provider, model, hermesAuthMethod, + compatibleEndpointReasoning, nimContainer, hermesToolGateways, }), @@ -521,6 +535,7 @@ export async function handleProviderInferenceState({ provider, model, hermesAuthMethod, + compatibleEndpointReasoning, nimContainer, hermesToolGateways, }), @@ -542,6 +557,7 @@ export async function handleProviderInferenceState({ hermesAuthMethod, hermesToolGateways, preferredInferenceApi, + compatibleEndpointReasoning, nimContainer, webSearchConfig, session, diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index 7801c0f2f80..54c6f270987 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -40,6 +40,7 @@ function context(overrides: Partial = {}): Context { hermesAuthMethod: null, hermesToolGateways: [], preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, webSearchConfig: null, webSearchSupported: false, diff --git a/src/lib/onboard/reasoning-mode.test.ts b/src/lib/onboard/reasoning-mode.test.ts new file mode 100644 index 00000000000..2062f8b80a1 --- /dev/null +++ b/src/lib/onboard/reasoning-mode.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from "vitest"; +import { + clearCompatibleEndpointReasoning, + configureCompatibleEndpointReasoning, + normalizeReasoningFlag, +} from "./reasoning-mode"; + +describe("compatible endpoint reasoning mode", () => { + afterEach(() => { + delete process.env.NEMOCLAW_REASONING; + }); + + it("normalizes supported boolean aliases (#3279)", () => { + for (const value of ["true", "1", "yes", "y", " YES "]) { + expect(normalizeReasoningFlag(value)).toBe("true"); + } + for (const value of ["false", "0", "no", "n", " NO "]) { + expect(normalizeReasoningFlag(value)).toBe("false"); + } + expect(normalizeReasoningFlag("maybe")).toBeNull(); + }); + + it("defaults an unset or invalid flag to false (#3279)", async () => { + await expect(configureCompatibleEndpointReasoning()).resolves.toBe("false"); + expect(process.env.NEMOCLAW_REASONING).toBe("false"); + + process.env.NEMOCLAW_REASONING = "maybe"; + await expect(configureCompatibleEndpointReasoning()).resolves.toBe("false"); + expect(process.env.NEMOCLAW_REASONING).toBe("false"); + }); + + it("restores stored state and clears it when the provider changes (#3279)", async () => { + process.env.NEMOCLAW_REASONING = "false"; + await expect(configureCompatibleEndpointReasoning("yes")).resolves.toBe("true"); + expect(process.env.NEMOCLAW_REASONING).toBe("true"); + + expect(clearCompatibleEndpointReasoning()).toBeNull(); + expect(process.env.NEMOCLAW_REASONING).toBeUndefined(); + }); +}); diff --git a/src/lib/onboard/reasoning-mode.ts b/src/lib/onboard/reasoning-mode.ts new file mode 100644 index 00000000000..52502ef9d1a --- /dev/null +++ b/src/lib/onboard/reasoning-mode.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function normalizeReasoningFlag(value: string | null | undefined): "true" | "false" | null { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "y") { + return "true"; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "n") { + return "false"; + } + return null; +} + +export async function configureCompatibleEndpointReasoning( + storedValue?: string | null, +): Promise<"true" | "false"> { + const configured = normalizeReasoningFlag(storedValue ?? process.env.NEMOCLAW_REASONING); + process.env.NEMOCLAW_REASONING = configured ?? "false"; + return process.env.NEMOCLAW_REASONING as "true" | "false"; +} + +export function clearCompatibleEndpointReasoning(): null { + delete process.env.NEMOCLAW_REASONING; + return null; +} diff --git a/src/lib/onboard/session-updates.ts b/src/lib/onboard/session-updates.ts index cf520c28ca1..d9dd4f315b1 100644 --- a/src/lib/onboard/session-updates.ts +++ b/src/lib/onboard/session-updates.ts @@ -13,6 +13,7 @@ export interface OnboardSessionUpdateInput { credentialEnv?: string | null; hermesAuthMethod?: HermesAuthMethod | string | null; preferredInferenceApi?: string | null; + compatibleEndpointReasoning?: string | null; nimContainer?: string | null; webSearchConfig?: WebSearchConfig | null; policyPresets?: string[] | null; @@ -47,6 +48,9 @@ export function toSessionUpdates(updates: OnboardSessionUpdateInput = {}): Sessi if (updates.preferredInferenceApi !== undefined) { normalized.preferredInferenceApi = toNullableString(updates.preferredInferenceApi); } + if (updates.compatibleEndpointReasoning !== undefined) { + normalized.compatibleEndpointReasoning = toNullableString(updates.compatibleEndpointReasoning); + } if (updates.nimContainer !== undefined) normalized.nimContainer = toNullableString(updates.nimContainer); if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; diff --git a/src/lib/onboard/setup-nim-selection.test.ts b/src/lib/onboard/setup-nim-selection.test.ts index 49b71d85bec..b224935d423 100644 --- a/src/lib/onboard/setup-nim-selection.test.ts +++ b/src/lib/onboard/setup-nim-selection.test.ts @@ -71,6 +71,8 @@ describe("createRemoteModelValidator", () => { state.endpointUrl = "https://compatible.example/v1"; state.model = "model-a"; let calledEndpoint: string | null = null; + let configuredReasoning = false; + const logLines: string[] = []; const { validateSelectedRemoteModel } = createRemoteModelValidator({ OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", @@ -89,6 +91,11 @@ describe("createRemoteModelValidator", () => { shouldRequireResponsesToolCalling: () => false, shouldSkipResponsesProbe: () => false, getProbeAuthMode: () => undefined, + configureCompatibleEndpointReasoning: async () => { + configuredReasoning = true; + return "true"; + }, + log: (message) => logLines.push(message), }); const result = await validateSelectedRemoteModel({ @@ -105,6 +112,11 @@ describe("createRemoteModelValidator", () => { assert.equal(result, "selected"); assert.equal(calledEndpoint, "https://compatible.example/v1"); assert.equal(state.preferredInferenceApi, "openai-completions"); + assert.equal(state.compatibleEndpointReasoning, "true"); + assert.equal(configuredReasoning, true); + assert.deepEqual(logLines, [ + " ⚠ Reasoning mode validates Chat Completions only; tools and streaming are unverified.", + ]); }); it("maps provider validation model retries without mutating selected model state", async () => { diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 406a3900cf2..5b8e1bc5491 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -11,6 +11,7 @@ export type SetupNimSelectionState = { hermesAuthMethod: THermesAuthMethod | null; hermesToolGateways: string[]; preferredInferenceApi: string | null; + compatibleEndpointReasoning?: string | null; nimContainer: string | null; allowToolsIncompatible: boolean; skipHostInferenceSmoke?: boolean; @@ -115,6 +116,8 @@ type RemoteModelValidatorDeps = { shouldRequireResponsesToolCalling: (provider: string) => boolean; shouldSkipResponsesProbe: (provider: string) => boolean; getProbeAuthMode: (provider: string) => ProbeAuthMode; + configureCompatibleEndpointReasoning?: () => Promise<"true" | "false">; + log?: (message: string) => void; }; type ValidateSelectedRemoteModelArgs = { @@ -150,6 +153,16 @@ export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): { `Missing model for ${remoteConfig.label}`, ); if (selected.key === "custom") { + // Reasoning mode is OpenAI-compatible only; Anthropic/native providers use other formats. + const reasoning = await deps.configureCompatibleEndpointReasoning?.(); + if (reasoning) { + state.compatibleEndpointReasoning = reasoning; + } + if (reasoning === "true") { + (deps.log ?? console.log)( + " ⚠ Reasoning mode validates Chat Completions only; tools and streaming are unverified.", + ); + } const validation = await deps.validateCustomOpenAiLikeSelection( remoteConfig.label, state.endpointUrl || deps.OPENAI_ENDPOINT_URL, diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index ba8c7505e1a..dda5efd6d74 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -443,6 +443,7 @@ describe("onboard session", () => { endpointUrl: "https://example.com/v1", credentialEnv: "NVIDIA_INFERENCE_API_KEY", preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: "true", nimContainer: "nim-123", policyPresets: ["pypi", "npm"], apiKey: "nvapi-secret", @@ -460,8 +461,12 @@ describe("onboard session", () => { expect(loaded.endpointUrl).toBe("https://example.com/v1"); expect(loaded.credentialEnv).toBe("NVIDIA_INFERENCE_API_KEY"); expect(loaded.preferredInferenceApi).toBe("openai-completions"); + expect(loaded.compatibleEndpointReasoning).toBe("true"); expect(loaded.nimContainer).toBe("nim-123"); expect(loaded.policyPresets).toEqual(["pypi", "npm"]); + expect(requireDebugSummary(session.summarizeForDebug()).compatibleEndpointReasoning).toBe( + "true", + ); expect("apiKey" in loaded).toBe(false); expect(loaded.metadata.gatewayName).toBe("nemoclaw"); expect("token" in loaded.metadata).toBe(false); @@ -591,7 +596,7 @@ describe("onboard session", () => { }); it("accepts null as an explicit clear for every nullable string field", () => { - // All six nullable fields that travel through filterSafeUpdates must + // All nullable fields that travel through filterSafeUpdates must // support the null-clear contract. If any regresses to the old // string-only guard, the test below catches it. session.saveSession(session.createSession()); @@ -602,6 +607,7 @@ describe("onboard session", () => { endpointUrl: "https://api.openai.com/v1", credentialEnv: "OPENAI_API_KEY", preferredInferenceApi: "openai-completions", + compatibleEndpointReasoning: "true", nimContainer: "nim-abc", }); @@ -612,6 +618,7 @@ describe("onboard session", () => { endpointUrl: null, credentialEnv: null, preferredInferenceApi: null, + compatibleEndpointReasoning: null, nimContainer: null, }); @@ -622,6 +629,7 @@ describe("onboard session", () => { expect(loaded.endpointUrl).toBeNull(); expect(loaded.credentialEnv).toBeNull(); expect(loaded.preferredInferenceApi).toBeNull(); + expect(loaded.compatibleEndpointReasoning).toBeNull(); expect(loaded.nimContainer).toBeNull(); }); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 7b199772a1d..6940c6fd0eb 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -100,6 +100,7 @@ export interface Session { credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; + compatibleEndpointReasoning: string | null; nimContainer: string | null; routerPid: number | null; routerCredentialHash: string | null; @@ -169,6 +170,7 @@ export interface SessionUpdates { credentialEnv?: string | null; hermesAuthMethod?: HermesAuthMethod | null; preferredInferenceApi?: string | null; + compatibleEndpointReasoning?: string | null; nimContainer?: string | null; routerPid?: number; routerCredentialHash?: string; @@ -198,6 +200,7 @@ export interface DebugSessionSummary { credentialEnv: string | null; hermesAuthMethod: HermesAuthMethod | null; preferredInferenceApi: string | null; + compatibleEndpointReasoning: string | null; nimContainer: string | null; hermesToolGateways: string[] | null; policyPresets: string[] | null; @@ -447,6 +450,7 @@ export function createSession(overrides: Partial = {}): Session { credentialEnv: overrides.credentialEnv ?? null, hermesAuthMethod: overrides.hermesAuthMethod ?? null, preferredInferenceApi: overrides.preferredInferenceApi ?? null, + compatibleEndpointReasoning: overrides.compatibleEndpointReasoning ?? null, nimContainer: overrides.nimContainer ?? null, routerPid: readPositiveInteger(overrides.routerPid), routerCredentialHash: overrides.routerCredentialHash ?? null, @@ -489,6 +493,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): credentialEnv: readString(data.credentialEnv), hermesAuthMethod: readHermesAuthMethod(data.hermesAuthMethod), preferredInferenceApi: readString(data.preferredInferenceApi), + compatibleEndpointReasoning: readString(data.compatibleEndpointReasoning), nimContainer: readString(data.nimContainer), routerPid: readPositiveInteger(data.routerPid), routerCredentialHash: readString(data.routerCredentialHash), @@ -969,6 +974,7 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { safe.hermesAuthMethod = null; } assignNullableString(safe, "preferredInferenceApi", updates.preferredInferenceApi); + assignNullableString(safe, "compatibleEndpointReasoning", updates.compatibleEndpointReasoning); assignNullableString(safe, "nimContainer", updates.nimContainer); if ( typeof updates.routerPid === "number" && @@ -1276,6 +1282,7 @@ export function summarizeForDebug( credentialEnv: session.credentialEnv, hermesAuthMethod: session.hermesAuthMethod, preferredInferenceApi: session.preferredInferenceApi, + compatibleEndpointReasoning: session.compatibleEndpointReasoning, nimContainer: session.nimContainer, hermesToolGateways: session.hermesToolGateways, policyPresets: session.policyPresets, diff --git a/test/generate-platform-docs.test.ts b/test/generate-platform-docs.test.ts index 30f477d8099..6c9c228ae5e 100644 --- a/test/generate-platform-docs.test.ts +++ b/test/generate-platform-docs.test.ts @@ -428,6 +428,15 @@ print(block) } }); + it("Option 3 docs expose reasoning mode for scripted compatible endpoints (#3279)", () => { + const body = readFileSync( + path.join(import.meta.dirname, "..", "docs", "inference", "inference-options.mdx"), + "utf-8", + ); + expect(body).toContain("| `NEMOCLAW_REASONING` |"); + expect(body).toContain("Set `NEMOCLAW_REASONING=true` when the compatible endpoint"); + }); + // PRA-2 on #5712 follow-up: a canonical launch-claims page that lives in the // repo but never appears in docs/index.yml is invisible on the published // site. Pin the registration so removing the nav entry fails CI before diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts index 864659b4a41..9a3d8e69c79 100644 --- a/test/helpers/onboard-final-flow-phases.ts +++ b/test/helpers/onboard-final-flow-phases.ts @@ -151,6 +151,7 @@ export function context( hermesAuthMethod: null, hermesToolGateways: ["local"], preferredInferenceApi: "chat", + compatibleEndpointReasoning: null, nimContainer: "nim-test", webSearchConfig: null, webSearchSupported: true, diff --git a/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts b/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts new file mode 100644 index 00000000000..3236e77d2d7 --- /dev/null +++ b/test/package-contract/onboard/compatible-endpoint-reasoning.test.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { it } from "vitest"; + +it("honors NEMOCLAW_REASONING for custom OpenAI-compatible endpoint models (#3279)", () => { + const repoRoot = path.join(import.meta.dirname, "..", "..", ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-reasoning-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-openai-reasoning-check.js"); + const curlArgsLog = path.join(tmpDir, "custom-openai-reasoning-curl-args.log"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "credentials", "store.js"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +args_log=${JSON.stringify(curlArgsLog)} +printf '%s\\n' "$*" >> "$args_log" +body='{"error":{"message":"bad request"}}' +status="400" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/chat/completions$'; then + body='{"id":"chatcmpl-123","choices":[{"message":{"content":"","reasoning_content":"OK"}}]}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["3", "https://proxy.example.com/v1", "reasoning-model"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_API_KEY = "proxy-key"; + process.env.NEMOCLAW_REASONING = "yes"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ + result, + messages, + lines, + reasoning: process.env.NEMOCLAW_REASONING, + })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().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 || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const stdoutLines = result.stdout.trim().split("\n"); + const payload = JSON.parse(stdoutLines.at(-1) || "{}"); + assert.equal(payload.result.provider, "compatible-endpoint"); + assert.equal(payload.result.model, "reasoning-model"); + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.equal(payload.reasoning, "true"); + assert.ok(payload.lines.some((line: string) => line.includes("tools and streaming"))); + const curlInvocations = fs.readFileSync(curlArgsLog, "utf-8"); + assert.match(curlInvocations, /chat\/completions/); + assert.doesNotMatch(curlInvocations, /\/responses/); + assert.doesNotMatch(curlInvocations, /(^|\s)-N(\s|$)/); + assert.ok( + payload.messages.every( + (message: string) => !/Enable reasoning mode for this model/.test(message), + ), + ); +});