From fac6dc5276c1729835765a658f701cf55f76fa24 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Mon, 29 Jun 2026 18:15:53 +0800 Subject: [PATCH 1/3] fix(inference): show registered providers and onboard tip on provider-not-found (#5924) When `openshell inference set` fails with a provider-not-found error, the error message now lists registered providers and suggests running `nemoclaw onboard` to register a new one. Co-Authored-By: Claude Opus 4 Signed-off-by: kagura-agent --- src/lib/actions/inference-set.test.ts | 72 ++++++++++++++++++++++++++- src/lib/actions/inference-set.ts | 30 ++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index b18e18fd6be..5c7fea1bf63 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -448,7 +448,7 @@ describe("runInferenceSet", () => { "nvidia/nemotron-3-super-120b-a12b", "--no-verify", ], - { ignoreError: true }, + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, ); expect(config.agents).toEqual({ defaults: { model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" } }, @@ -540,7 +540,7 @@ describe("runInferenceSet", () => { "openai/gpt-5.4-mini", "--no-verify", ], - { ignoreError: true }, + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, ); expect(config).toEqual({ _nemoclaw_upstream: { @@ -1110,6 +1110,74 @@ describe("runInferenceSet", () => { expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); }); + it("includes registered providers and onboard tip when openshell reports provider not found (#5924)", async () => { + const deps = createDeps({ + config: {}, + entries: [ + { name: "alpha", agent: "openclaw", provider: "nvidia-prod", model: "nvidia/model-a" }, + { name: "beta", agent: "openclaw", provider: "anthropic-prod", model: "claude-sonnet-4-6" }, + ], + openshellStatus: 1, + }); + deps.calls.runOpenshell.mockReturnValue({ + status: 1, + stdout: "", + stderr: "error: provider 'bad-provider' not found in gateway", + }); + + const err = await runInferenceSet( + { provider: "nvidia-prod", model: "nvidia/model-a" }, + deps, + ).catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/Registered providers: nvidia-prod, anthropic-prod/); + expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); + expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); + expect(deps.calls.updateSandbox).not.toHaveBeenCalled(); + }); + + it("throws the generic error when openshell fails without a provider-not-found pattern (#5924)", async () => { + const deps = createDeps({ config: {}, openshellStatus: 42 }); + deps.calls.runOpenshell.mockReturnValue({ + status: 42, + stdout: "", + stderr: "error: network timeout connecting to gateway", + }); + + const err = await runInferenceSet( + { provider: "nvidia-prod", model: "nvidia/model-a" }, + deps, + ).catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/OpenShell inference route update failed with exit 42/); + expect(err.message).not.toMatch(/Registered providers/); + expect(err.message).not.toMatch(/onboard/); + }); + + it("shows 'No providers registered' when no sandbox has a provider on provider-not-found (#5924)", async () => { + const deps = createDeps({ + config: {}, + entries: [{ name: "alpha", agent: "openclaw", provider: null, model: null }], + openshellStatus: 1, + }); + deps.calls.runOpenshell.mockReturnValue({ + status: 1, + stdout: "", + stderr: "error: provider 'openai-api' not found in gateway", + }); + + const err = await runInferenceSet( + { provider: "nvidia-prod", model: "nvidia/model-a" }, + deps, + ).catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/No providers registered/); + expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); + }); + it("keeps gateway and registry consistent when the sandbox config read fails", async () => { const deps = createDeps({ config: {}, session: baseSession() }); deps.calls.readSandboxConfig.mockImplementation(() => { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index b258fd99222..c9f6772f765 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -72,7 +72,7 @@ export interface InferenceSetDeps { config: ConfigObject, ) => void; recomputeSandboxConfigHash: (sandboxName: string, target: AgentConfigTarget) => void; - runOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => OpenshellRunResult; + runOpenshell: (args: string[], opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions }) => OpenshellRunResult; appendAuditEntry: typeof appendAuditEntry; log: (message: string) => void; isLocalInferenceProvider: (provider: string) => boolean; @@ -609,9 +609,37 @@ export async function runInferenceSet( openshellInferenceSetArgs({ provider, model, noVerify: effectiveNoVerify }), { ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], }, ); if (setResult.status !== 0) { + const stderr = typeof setResult.stderr === "string" ? setResult.stderr : ""; + const stdout = typeof setResult.stdout === "string" ? setResult.stdout : ""; + const combined = `${stderr}\n${stdout}`; + if (/provider.*not found/i.test(combined) || /not found.*provider/i.test(combined)) { + let providerList = "No providers registered"; + try { + const registeredProviders = [ + ...new Set( + deps + .listSandboxes() + .sandboxes.map((s) => s.provider) + .filter((p): p is string => typeof p === "string" && p.length > 0), + ), + ]; + if (registeredProviders.length > 0) { + providerList = `Registered providers: ${registeredProviders.join(", ")}`; + } + } catch { + // Registry unavailable — still show the onboard tip without provider details. + } + throw new InferenceSetError( + `OpenShell inference route update failed with exit ${setResult.status ?? 1}.\n` + + `${providerList}\n` + + `Tip: register a new provider with \`${CLI_NAME} onboard\`.`, + setResult.status ?? 1, + ); + } throw new InferenceSetError( `OpenShell inference route update failed with exit ${setResult.status ?? 1}.`, setResult.status ?? 1, From 30d0c86ea836b03b3ff3aa16c48f990cf9cf0804 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Mon, 29 Jun 2026 18:30:10 +0800 Subject: [PATCH 2/3] fix: handle registry errors and add listSandboxes throw test Address CodeRabbit review: - Don't report 'No providers registered' when listSandboxes() throws - Add regression test for listSandboxes() throwing Signed-off-by: kagura-agent --- src/lib/actions/inference-set.test.ts | 22 ++++++++++++++++++++++ src/lib/actions/inference-set.ts | 11 ++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index 5c7fea1bf63..32ce9bd9568 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -1178,6 +1178,28 @@ describe("runInferenceSet", () => { expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); }); + it("omits provider list and still shows onboard tip when listSandboxes throws (#5924)", async () => { + const deps = createDeps({ config: {}, openshellStatus: 1 }); + deps.calls.runOpenshell.mockReturnValue({ + status: 1, + stdout: "", + stderr: "error: provider 'openai-api' not found in gateway", + }); + deps.listSandboxes = () => { + throw new Error("registry corrupted"); + }; + + const err = await runInferenceSet( + { provider: "nvidia-prod", model: "nvidia/model-a" }, + deps, + ).catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.message).not.toMatch(/Registered providers/); + expect(err.message).not.toMatch(/No providers registered/); + expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); + }); + it("keeps gateway and registry consistent when the sandbox config read fails", async () => { const deps = createDeps({ config: {}, session: baseSession() }); deps.calls.readSandboxConfig.mockImplementation(() => { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index c9f6772f765..60b3ef41445 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -617,7 +617,7 @@ export async function runInferenceSet( const stdout = typeof setResult.stdout === "string" ? setResult.stdout : ""; const combined = `${stderr}\n${stdout}`; if (/provider.*not found/i.test(combined) || /not found.*provider/i.test(combined)) { - let providerList = "No providers registered"; + let providerList: string | null = null; try { const registeredProviders = [ ...new Set( @@ -627,15 +627,16 @@ export async function runInferenceSet( .filter((p): p is string => typeof p === "string" && p.length > 0), ), ]; - if (registeredProviders.length > 0) { - providerList = `Registered providers: ${registeredProviders.join(", ")}`; - } + providerList = + registeredProviders.length > 0 + ? `Registered providers: ${registeredProviders.join(", ")}` + : "No providers registered"; } catch { // Registry unavailable — still show the onboard tip without provider details. } throw new InferenceSetError( `OpenShell inference route update failed with exit ${setResult.status ?? 1}.\n` + - `${providerList}\n` + + `${providerList ? `${providerList}\n` : ""}` + `Tip: register a new provider with \`${CLI_NAME} onboard\`.`, setResult.status ?? 1, ); From f9f873b1eefb883d52ee600c250ab6fb7b6bd3df Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 29 Jun 2026 21:27:12 -0700 Subject: [PATCH 3/3] fix(inference): preserve captured failure details --- src/lib/actions/inference-set.test.ts | 17 ++++++++++++----- src/lib/actions/inference-set.ts | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index 32ce9bd9568..d4c9ed6003c 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -1122,15 +1122,17 @@ describe("runInferenceSet", () => { deps.calls.runOpenshell.mockReturnValue({ status: 1, stdout: "", - stderr: "error: provider 'bad-provider' not found in gateway", + stderr: "error: provider 'openai-api' not found in gateway", }); const err = await runInferenceSet( - { provider: "nvidia-prod", model: "nvidia/model-a" }, + { provider: "openai-api", model: "openai/gpt-5.4-mini" }, deps, ).catch((e: Error) => e); expect(err).toBeInstanceOf(Error); + if (!(err instanceof Error)) throw new Error("Expected inference set to fail"); + expect(err.message).toMatch(/provider 'openai-api' not found/); expect(err.message).toMatch(/Registered providers: nvidia-prod, anthropic-prod/); expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); expect(deps.calls.writeSandboxConfig).not.toHaveBeenCalled(); @@ -1142,7 +1144,7 @@ describe("runInferenceSet", () => { deps.calls.runOpenshell.mockReturnValue({ status: 42, stdout: "", - stderr: "error: network timeout connecting to gateway", + stderr: "error: network timeout connecting to gateway NVIDIA_API_KEY=nvapi-secret-value", }); const err = await runInferenceSet( @@ -1151,7 +1153,10 @@ describe("runInferenceSet", () => { ).catch((e: Error) => e); expect(err).toBeInstanceOf(Error); + if (!(err instanceof Error)) throw new Error("Expected inference set to fail"); expect(err.message).toMatch(/OpenShell inference route update failed with exit 42/); + expect(err.message).toMatch(/network timeout connecting to gateway/); + expect(err.message).not.toContain("nvapi-secret-value"); expect(err.message).not.toMatch(/Registered providers/); expect(err.message).not.toMatch(/onboard/); }); @@ -1169,11 +1174,12 @@ describe("runInferenceSet", () => { }); const err = await runInferenceSet( - { provider: "nvidia-prod", model: "nvidia/model-a" }, + { provider: "openai-api", model: "openai/gpt-5.4-mini" }, deps, ).catch((e: Error) => e); expect(err).toBeInstanceOf(Error); + if (!(err instanceof Error)) throw new Error("Expected inference set to fail"); expect(err.message).toMatch(/No providers registered/); expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); }); @@ -1190,11 +1196,12 @@ describe("runInferenceSet", () => { }; const err = await runInferenceSet( - { provider: "nvidia-prod", model: "nvidia/model-a" }, + { provider: "openai-api", model: "openai/gpt-5.4-mini" }, deps, ).catch((e: Error) => e); expect(err).toBeInstanceOf(Error); + if (!(err instanceof Error)) throw new Error("Expected inference set to fail"); expect(err.message).not.toMatch(/Registered providers/); expect(err.message).not.toMatch(/No providers registered/); expect(err.message).toMatch(/Tip: register a new provider with `nemoclaw onboard`/); diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 60b3ef41445..d0e9ef9c7e0 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -5,6 +5,7 @@ import type { SpawnSyncReturns } from "node:child_process"; import { runOpenshell } from "../adapters/openshell/runtime"; import { CLI_NAME } from "../cli/branding"; +import { compactText } from "../core/url-utils"; import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; import { getProviderSelectionConfig, @@ -12,8 +13,8 @@ import { type SandboxInferenceConfig, } from "../inference/config"; import { resolveContextWindowForModel } from "../inference/context-window"; -import { inferenceSelectionRegistryFields } from "../inference/selection"; import { type ValidationResult, validateLocalProvider } from "../inference/local"; +import { inferenceSelectionRegistryFields } from "../inference/selection"; import { ensureLocalProviderReachable } from "../onboard/local-inference-topology"; import { type AgentConfigTarget, @@ -24,6 +25,7 @@ import { } from "../sandbox/config"; import type { ConfigObject, ConfigValue } from "../security/credential-filter"; import { isConfigObject, isConfigValue } from "../security/credential-filter"; +import { redact } from "../security/redact"; import { appendAuditEntry } from "../shields/audit"; import * as onboardSession from "../state/onboard-session"; import type { SandboxEntry } from "../state/registry"; @@ -72,7 +74,10 @@ export interface InferenceSetDeps { config: ConfigObject, ) => void; recomputeSandboxConfigHash: (sandboxName: string, target: AgentConfigTarget) => void; - runOpenshell: (args: string[], opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions }) => OpenshellRunResult; + runOpenshell: ( + args: string[], + opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions }, + ) => OpenshellRunResult; appendAuditEntry: typeof appendAuditEntry; log: (message: string) => void; isLocalInferenceProvider: (provider: string) => boolean; @@ -358,6 +363,10 @@ function openshellInferenceSetArgs(options: { return args; } +function openshellFailureDetail(stderr: string, stdout: string): string { + return compactText(redact(`${stderr}\n${stdout}`)).slice(0, 500); +} + function getPreferredInferenceApi(config: ConfigObject): string | null { const models = config.models; if (!isConfigObject(models)) return null; @@ -616,6 +625,8 @@ export async function runInferenceSet( const stderr = typeof setResult.stderr === "string" ? setResult.stderr : ""; const stdout = typeof setResult.stdout === "string" ? setResult.stdout : ""; const combined = `${stderr}\n${stdout}`; + const failureDetail = openshellFailureDetail(stderr, stdout); + const failureDetailLine = failureDetail ? `OpenShell detail: ${failureDetail}\n` : ""; if (/provider.*not found/i.test(combined) || /not found.*provider/i.test(combined)) { let providerList: string | null = null; try { @@ -636,13 +647,15 @@ export async function runInferenceSet( } throw new InferenceSetError( `OpenShell inference route update failed with exit ${setResult.status ?? 1}.\n` + + failureDetailLine + `${providerList ? `${providerList}\n` : ""}` + `Tip: register a new provider with \`${CLI_NAME} onboard\`.`, setResult.status ?? 1, ); } throw new InferenceSetError( - `OpenShell inference route update failed with exit ${setResult.status ?? 1}.`, + `OpenShell inference route update failed with exit ${setResult.status ?? 1}.` + + `${failureDetail ? `\nOpenShell detail: ${failureDetail}` : ""}`, setResult.status ?? 1, ); }