From efd5a8f0e9c1b5cd7b392e7932b82e0eda6e6756 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 16 Apr 2026 21:01:46 -0700 Subject: [PATCH] fix(cli): show inference health in sandbox status output (Fixes #995) sandboxStatus() already probed local providers (vllm-local, ollama-local) but showed no Inference line for remote providers. Add a unified probeProviderHealth() dispatcher that performs lightweight reachability checks for remote cloud endpoints (nvidia-prod, openai-api, anthropic-prod, gemini-api) and a "not probed" fallback for compatible-* providers whose URLs are unknown. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/inference-health.test.ts | 248 +++++++++++++++++++++++++++++++ src/lib/inference-health.ts | 136 +++++++++++++++++ src/nemoclaw.ts | 24 +-- 3 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 src/lib/inference-health.test.ts create mode 100644 src/lib/inference-health.ts diff --git a/src/lib/inference-health.test.ts b/src/lib/inference-health.test.ts new file mode 100644 index 00000000000..137fb64bd44 --- /dev/null +++ b/src/lib/inference-health.test.ts @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; + +// Import from compiled dist/ for correct coverage attribution. +import { + getRemoteProviderHealthEndpoint, + probeRemoteProviderHealth, + probeProviderHealth, +} from "../../dist/lib/inference-health"; + +import { BUILD_ENDPOINT_URL } from "../../dist/lib/provider-models"; + +describe("inference health", () => { + describe("getRemoteProviderHealthEndpoint", () => { + it("returns NVIDIA endpoint for nvidia-prod", () => { + expect(getRemoteProviderHealthEndpoint("nvidia-prod")).toBe(`${BUILD_ENDPOINT_URL}/models`); + }); + + it("returns NVIDIA endpoint for nvidia-nim", () => { + expect(getRemoteProviderHealthEndpoint("nvidia-nim")).toBe(`${BUILD_ENDPOINT_URL}/models`); + }); + + it("returns OpenAI endpoint for openai-api", () => { + expect(getRemoteProviderHealthEndpoint("openai-api")).toBe( + "https://api.openai.com/v1/models", + ); + }); + + it("returns Anthropic endpoint for anthropic-prod", () => { + expect(getRemoteProviderHealthEndpoint("anthropic-prod")).toBe( + "https://api.anthropic.com/v1/models", + ); + }); + + it("returns Gemini endpoint for gemini-api", () => { + expect(getRemoteProviderHealthEndpoint("gemini-api")).toBe( + "https://generativelanguage.googleapis.com/v1/models", + ); + }); + + it("returns null for compatible-endpoint", () => { + expect(getRemoteProviderHealthEndpoint("compatible-endpoint")).toBeNull(); + }); + + it("returns null for compatible-anthropic-endpoint", () => { + expect(getRemoteProviderHealthEndpoint("compatible-anthropic-endpoint")).toBeNull(); + }); + + it("returns null for local providers", () => { + expect(getRemoteProviderHealthEndpoint("ollama-local")).toBeNull(); + expect(getRemoteProviderHealthEndpoint("vllm-local")).toBeNull(); + }); + + it("returns null for unknown providers", () => { + expect(getRemoteProviderHealthEndpoint("unknown-provider")).toBeNull(); + }); + }); + + describe("probeRemoteProviderHealth", () => { + it("reports reachable when endpoint returns HTTP 200", () => { + const result = probeRemoteProviderHealth("openai-api", { + runCurlProbeImpl: () => ({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "{}", + stderr: "", + message: "HTTP 200", + }), + }); + + expect(result).toEqual({ + ok: true, + probed: true, + providerLabel: "OpenAI", + endpoint: "https://api.openai.com/v1/models", + detail: "OpenAI endpoint is reachable at https://api.openai.com/v1/models.", + }); + }); + + it("reports reachable when endpoint returns HTTP 401 (auth required)", () => { + const result = probeRemoteProviderHealth("nvidia-prod", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 401, + curlStatus: 0, + body: '{"error":"unauthorized"}', + stderr: "", + message: "HTTP 401: unauthorized", + }), + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + expect(result?.detail).toContain("reachable"); + }); + + it("reports reachable when endpoint returns HTTP 403 (forbidden)", () => { + const result = probeRemoteProviderHealth("anthropic-prod", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 403, + curlStatus: 0, + body: "", + stderr: "", + message: "HTTP 403", + }), + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + }); + + it("reports unreachable when connection is refused", () => { + const result = probeRemoteProviderHealth("openai-api", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 0, + curlStatus: 7, + body: "", + stderr: "Failed to connect", + message: "curl failed (exit 7): Failed to connect", + }), + }); + + expect(result?.ok).toBe(false); + expect(result?.probed).toBe(true); + expect(result?.detail).toContain("unreachable"); + expect(result?.detail).toContain("Check your network connection"); + }); + + it("reports unreachable on timeout", () => { + const result = probeRemoteProviderHealth("gemini-api", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 0, + curlStatus: 28, + body: "", + stderr: "Operation timed out", + message: "curl failed (exit 28): Operation timed out", + }), + }); + + expect(result?.ok).toBe(false); + expect(result?.probed).toBe(true); + expect(result?.endpoint).toBe("https://generativelanguage.googleapis.com/v1/models"); + }); + + it("returns not-probed status for compatible-endpoint", () => { + const result = probeRemoteProviderHealth("compatible-endpoint"); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(false); + expect(result?.detail).toContain("not known"); + }); + + it("returns not-probed status for compatible-anthropic-endpoint", () => { + const result = probeRemoteProviderHealth("compatible-anthropic-endpoint"); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(false); + }); + + it("returns null for local providers", () => { + expect(probeRemoteProviderHealth("ollama-local")).toBeNull(); + expect(probeRemoteProviderHealth("vllm-local")).toBeNull(); + }); + + it("returns null for unknown providers", () => { + expect(probeRemoteProviderHealth("unknown-provider")).toBeNull(); + }); + + it("passes correct curl arguments to the probe", () => { + let capturedArgv: string[] = []; + probeRemoteProviderHealth("openai-api", { + runCurlProbeImpl: (argv) => { + capturedArgv = argv; + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "", + stderr: "", + message: "", + }; + }, + }); + + expect(capturedArgv).toEqual([ + "-sS", + "--connect-timeout", + "3", + "--max-time", + "5", + "https://api.openai.com/v1/models", + ]); + }); + }); + + describe("probeProviderHealth (unified)", () => { + it("delegates to local probe for ollama-local", () => { + const result = probeProviderHealth("ollama-local", { + runCurlProbeImpl: () => ({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body: "{}", + stderr: "", + message: "HTTP 200", + }), + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + expect(result?.providerLabel).toBe("Local Ollama"); + expect(result?.endpoint).toBe("http://127.0.0.1:11434/api/tags"); + }); + + it("delegates to remote probe for openai-api", () => { + const result = probeProviderHealth("openai-api", { + runCurlProbeImpl: () => ({ + ok: false, + httpStatus: 401, + curlStatus: 0, + body: "", + stderr: "", + message: "HTTP 401", + }), + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + expect(result?.providerLabel).toBe("OpenAI"); + }); + + it("returns not-probed for compatible-endpoint", () => { + const result = probeProviderHealth("compatible-endpoint"); + + expect(result?.probed).toBe(false); + }); + + it("returns null for unknown providers", () => { + expect(probeProviderHealth("bogus-provider")).toBeNull(); + }); + }); +}); diff --git a/src/lib/inference-health.ts b/src/lib/inference-health.ts new file mode 100644 index 00000000000..1b7314df1e4 --- /dev/null +++ b/src/lib/inference-health.ts @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Unified inference health probing for both local and remote providers. + * Delegates to probeLocalProviderHealth for vllm-local/ollama-local, + * and performs lightweight reachability checks for remote cloud providers. + */ + +import type { CurlProbeResult } from "./http-probe"; +import { runCurlProbe } from "./http-probe"; +import { getProviderSelectionConfig } from "./inference-config"; +import type { LocalProviderHealthProbeOptions } from "./local-inference"; +import { probeLocalProviderHealth } from "./local-inference"; +import { BUILD_ENDPOINT_URL } from "./provider-models"; + +export interface ProviderHealthStatus { + ok: boolean; + probed: boolean; + providerLabel: string; + endpoint: string; + detail: string; +} + +export interface ProviderHealthProbeOptions { + runCurlProbeImpl?: (argv: string[]) => CurlProbeResult; +} + +const COMPATIBLE_PROVIDERS = new Set(["compatible-endpoint", "compatible-anthropic-endpoint"]); + +/** + * Maps remote provider names to their health-check endpoints. + * Returns null for local providers, compatible-* providers (unknown URL), + * and unrecognized provider names. + */ +export function getRemoteProviderHealthEndpoint(provider: string): string | null { + switch (provider) { + case "nvidia-prod": + case "nvidia-nim": + return `${BUILD_ENDPOINT_URL}/models`; + case "openai-api": + return "https://api.openai.com/v1/models"; + case "anthropic-prod": + return "https://api.anthropic.com/v1/models"; + case "gemini-api": + return "https://generativelanguage.googleapis.com/v1/models"; + default: + return null; + } +} + +function buildRemoteProbeDetail( + providerLabel: string, + endpoint: string, + reachable: boolean, + result: CurlProbeResult, +): string { + if (reachable) { + return `${providerLabel} endpoint is reachable at ${endpoint}.`; + } + return ( + `${providerLabel} endpoint at ${endpoint} is unreachable. ` + + `Check your network connection. (${result.message})` + ); +} + +/** + * Probes a remote provider endpoint for reachability. + * Any HTTP response (including 401/403) counts as reachable — we are + * not authenticating, just checking that the endpoint is up. + * + * Returns null for local providers and unrecognized providers. + * Returns a "not probed" status for compatible-* providers (unknown URL). + */ +export function probeRemoteProviderHealth( + provider: string, + options: ProviderHealthProbeOptions = {}, +): ProviderHealthStatus | null { + const config = getProviderSelectionConfig(provider); + const providerLabel = config?.providerLabel ?? provider; + + if (COMPATIBLE_PROVIDERS.has(provider)) { + return { + ok: true, + probed: false, + providerLabel, + endpoint: "", + detail: "Endpoint URL is not known; skipping reachability check.", + }; + } + + const endpoint = getRemoteProviderHealthEndpoint(provider); + if (!endpoint) { + return null; + } + + const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; + const result = runCurlProbeImpl(["-sS", "--connect-timeout", "3", "--max-time", "5", endpoint]); + + // For remote providers, curlStatus === 0 means curl connected and got an + // HTTP response. Even a 401/403 means the endpoint is reachable. + const reachable = result.curlStatus === 0; + + return { + ok: reachable, + probed: true, + providerLabel, + endpoint, + detail: buildRemoteProbeDetail(providerLabel, endpoint, reachable, result), + }; +} + +/** + * Unified provider health probe — tries local first, then remote. + * Returns null only for completely unrecognized providers. + */ +export function probeProviderHealth( + provider: string, + options: ProviderHealthProbeOptions = {}, +): ProviderHealthStatus | null { + const localOptions: LocalProviderHealthProbeOptions = { + runCurlProbeImpl: options.runCurlProbeImpl, + }; + const local = probeLocalProviderHealth(provider, localOptions); + if (local) { + return { + ok: local.ok, + probed: true, + providerLabel: local.providerLabel, + endpoint: local.endpoint, + detail: local.detail, + }; + } + + return probeRemoteProviderHealth(provider, options); +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 2f7ec71c1dd..a30f0da659c 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -44,7 +44,7 @@ const policies = require("./lib/policies"); const shields = require("./lib/shields"); const sandboxConfig = require("./lib/sandbox-config"); const { parseGatewayInference } = require("./lib/inference-config"); -const { probeLocalProviderHealth } = require("./lib/local-inference"); +const { probeProviderHealth } = require("./lib/inference-health"); const { getVersion } = require("./lib/version"); const onboardSession = require("./lib/onboard-session"); const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); @@ -1204,19 +1204,25 @@ async function sandboxStatus(sandboxName) { ); const currentModel = (live && live.model) || (sb && sb.model) || "unknown"; const currentProvider = (live && live.provider) || (sb && sb.provider) || "unknown"; - const localInferenceHealth = - typeof currentProvider === "string" ? probeLocalProviderHealth(currentProvider) : null; + const inferenceHealth = + typeof currentProvider === "string" ? probeProviderHealth(currentProvider) : null; if (sb) { console.log(""); console.log(` Sandbox: ${sb.name}`); console.log(` Model: ${currentModel}`); console.log(` Provider: ${currentProvider}`); - if (localInferenceHealth) { - console.log( - ` Inference: ${localInferenceHealth.ok ? `${G}healthy${R}` : `${_RD}unreachable${R}`} (${localInferenceHealth.endpoint})`, - ); - if (!localInferenceHealth.ok) { - console.log(` ${localInferenceHealth.detail}`); + if (inferenceHealth) { + if (!inferenceHealth.probed) { + console.log(` Inference: ${D}not probed${R} (${inferenceHealth.detail})`); + } else if (inferenceHealth.ok) { + console.log( + ` Inference: ${G}healthy${R} (${inferenceHealth.endpoint})`, + ); + } else { + console.log( + ` Inference: ${_RD}unreachable${R} (${inferenceHealth.endpoint})`, + ); + console.log(` ${inferenceHealth.detail}`); } } console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`);