Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions src/lib/actions/inference-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1110,6 +1110,103 @@ 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 'openai-api' not found in gateway",
});

const err = await runInferenceSet(
{ provider: "openai-api", model: "openai/gpt-5.4-mini" },
deps,
).catch((e: Error) => e);

expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
expect(message).toMatch(/provider 'openai-api' not found/);
expect(message).toMatch(/Registered providers: nvidia-prod, anthropic-prod/);
expect(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 NVIDIA_API_KEY=nvapi-secret-value",
});

const err = await runInferenceSet(
{ provider: "nvidia-prod", model: "nvidia/model-a" },
deps,
).catch((e: Error) => e);

expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
expect(message).toMatch(/OpenShell inference route update failed with exit 42/);
expect(message).toMatch(/network timeout connecting to gateway/);
expect(message).not.toContain("nvapi-secret-value");
expect(message).not.toMatch(/Registered providers/);
expect(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: "openai-api", model: "openai/gpt-5.4-mini" },
deps,
).catch((e: Error) => e);

expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
expect(message).toMatch(/No providers registered/);
expect(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: "openai-api", model: "openai/gpt-5.4-mini" },
deps,
).catch((e: Error) => e);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
expect(message).not.toMatch(/Registered providers/);
expect(message).not.toMatch(/No providers registered/);
expect(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(() => {
Expand Down
48 changes: 45 additions & 3 deletions src/lib/actions/inference-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ 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,
getSandboxInferenceConfig,
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,
Expand All @@ -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";
Expand Down Expand Up @@ -72,7 +74,10 @@ 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -609,11 +618,44 @@ 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}`;
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 {
const registeredProviders = [
...new Set(
deps
.listSandboxes()
.sandboxes.map((s) => s.provider)
.filter((p): p is string => typeof p === "string" && p.length > 0),
),
];
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` +
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,
);
}
Expand Down
Loading