Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3925,6 +3925,7 @@ async function setupNim(
gpu: ReturnType<typeof nim.detectGpu>,
sandboxName: string | null = null,
agent: AgentDefinition | null = null,
recoverProvider = true,
): Promise<{
model: string | null;
provider: string;
Expand Down Expand Up @@ -3972,13 +3973,12 @@ async function setupNim(
ollamaInstallMenu,
gpuNimCapable,
} = providerHostState;
const requestedProvider = getNonInteractiveProvider();
const requestedProvider = getNonInteractiveProvider() || (recoverProvider ? null : "build");
const requestedModel = isNonInteractive()
? getNonInteractiveModel(requestedProvider || "build")
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const agentProviderOptions = getAgentInferenceProviderOptions(agent);

// Model Router: complexity-based routing via blueprint config.
const blueprintRouterCfg = loadBlueprintProfile("routed");
const { options, hermesProviderAvailable } = buildInferenceProviderMenu({
remoteProviderConfig: REMOTE_PROVIDER_CONFIG,
Expand Down
23 changes: 23 additions & 0 deletions src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,29 @@ describe("core onboard flow phases", () => {
expect(Array.isArray(result.result)).toBe(true);
});

it("passes fresh context through to provider setup recovery policy", async () => {
const setupNim = vi.fn(async () => ({
model: "nvidia/test",
provider: "nim",
endpointUrl: "https://example.test/v1",
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
hermesAuthMethod: null,
hermesToolGateways: [],
preferredInferenceApi: "chat",
nimContainer: null,
}));
const [providerPhase] = createPhases({ providerDeps: { setupNim } });

await providerPhase.run(context({ fresh: true }));

expect(setupNim).toHaveBeenCalledWith(
{ platform: "linux" },
"my-sandbox",
{ name: "openclaw" },
false,
);
});

it("uses normalized context Hermes tool gateways for provider inference resume", async () => {
const setupInference = vi.fn(async () => ({ ok: true as const }));
const [providerPhase] = createPhases({
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/core-flow-phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function createCoreOnboardFlowPhases<
async run(context) {
const providerInferenceResult = await handleProviderInferenceState({
resume: context.resume,
fresh: context.fresh,
session: context.session,
gpu: context.gpu,
sandboxName: context.sandboxName,
Expand Down
15 changes: 14 additions & 1 deletion src/lib/onboard/machine/handlers/provider-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ function baseOptions(
): ProviderInferenceStateOptions<Gpu, Agent, Host> {
return {
resume: false,
fresh: false,
session,
gpu: { type: "nvidia" },
sandboxName: null,
Expand Down Expand Up @@ -143,7 +144,7 @@ describe("handleProviderInferenceState", () => {
const result = await handleProviderInferenceState(baseOptions(deps));

expect(calls.startStep).toHaveBeenNthCalledWith(1, "provider_selection");
expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, null, null);
expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, null, null, true);
expect(calls.complete).toHaveBeenCalledWith(
"provider_selection",
expect.objectContaining({ provider: "nvidia-prod" }),
Expand Down Expand Up @@ -191,6 +192,18 @@ describe("handleProviderInferenceState", () => {
]);
});

it("disables recorded provider recovery during fresh provider selection", async () => {
const { deps, calls } = createDeps();

await handleProviderInferenceState({
...baseOptions(deps),
fresh: true,
sandboxName: "dcode-station",
});

expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, "dcode-station", null, false);
});

it("clears non-NVIDIA provider credentials when inference setup fails", async () => {
const setupNim = vi.fn(async () => ({
...baseSelection,
Expand Down
11 changes: 9 additions & 2 deletions src/lib/onboard/machine/handlers/provider-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ProviderSelectionResult {

export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
resume: boolean;
fresh: boolean;
session: Session | null;
gpu: Gpu;
sandboxName: string | null;
Expand All @@ -48,7 +49,12 @@ export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
};
deps: {
normalizeHermesAuthMethod(value: string | null | undefined): string | null;
setupNim(gpu: Gpu, sandboxName: string | null, agent: Agent): Promise<ProviderSelectionResult>;
setupNim(
gpu: Gpu,
sandboxName: string | null,
agent: Agent,
allowRecordedProviderRecovery?: boolean,
): Promise<ProviderSelectionResult>;
setupInference(
sandboxName: string | null,
model: string,
Expand Down Expand Up @@ -167,6 +173,7 @@ function clearStagedCredentialEnv(

export async function handleProviderInferenceState<Gpu, Agent, Host>({
resume,
fresh,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
session,
gpu,
sandboxName,
Expand Down Expand Up @@ -246,7 +253,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
const selection = await withProviderSelectionTrace(
sandboxName,
(agent as { name?: string } | null)?.name,
() => deps.setupNim(gpu, sandboxName, agent),
() => deps.setupNim(gpu, sandboxName, agent, !fresh),
);
model = selection.model;
provider = selection.provider;
Expand Down
120 changes: 120 additions & 0 deletions test/onboard-resume-provider-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,123 @@ console.log(JSON.stringify({
expect(payload.model).toBe("qwen2.5:14b");
});
});

describe("setupNim provider recovery policy", () => {
it("ignores stale recorded providers when fresh setup disables provider recovery", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-fresh-provider-"));
const fakeBin = path.join(tmpDir, "bin");
const scriptPath = path.join(tmpDir, "fresh-provider-recovery-check.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const sessionPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "state", "onboard-session.js"),
);
const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.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
body='{"choices":[{"message":{"role":"assistant","content":"OK"}}]}'
status="200"
outfile=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) outfile="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$body" > "$outfile"
printf '%s' "$status"
`,
{ mode: 0o755 },
);

const script = String.raw`
const fs = require("fs");
const path = require("path");
const Module = require("module");
const runner = require(${runnerPath});
const registry = require(${registryPath});
const onboardSession = require(${sessionPath});

runner.runCapture = () => "";
registry.getSandbox = () => null;
registry.listSandboxes = () => ({ sandboxes: [], defaultSandbox: null });
onboardSession.loadSession = () => ({
sandboxName: "dcode-station",
provider: "ollama-local",
model: "llama3.1",
});

const onboardFile = ${onboardPath};
const source = fs.readFileSync(onboardFile, "utf-8");
const injected = source + "\nmodule.exports.__setNonInteractive = (value) => { NON_INTERACTIVE = value; };";
const onboardModule = new Module(onboardFile, module);
onboardModule.filename = onboardFile;
onboardModule.paths = Module._nodeModulePaths(path.dirname(onboardFile));
onboardModule._compile(injected, onboardFile);

const { setupNim, __setNonInteractive } = onboardModule.exports;

(async () => {
for (const key of [
"NEMOCLAW_PROVIDER",
"NEMOCLAW_PROVIDER_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"COMPATIBLE_API_KEY",
"COMPATIBLE_ANTHROPIC_API_KEY",
]) {
delete process.env[key];
}
process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-test";
process.env.NEMOCLAW_MODEL = "nvidia/test-model";
__setNonInteractive(true);
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, "dcode-station", null, false);
originalLog(JSON.stringify({ result, lines }));
} 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 || ""}`,
NEMOCLAW_TEST_NO_SLEEP: "1",
},
});

if (result.status !== 0) {
throw new Error(result.stderr || result.stdout);
}
const payload = JSON.parse(result.stdout.trim());
expect(payload.result.provider).toBe("nvidia-prod");
expect(payload.result.model).toBe("nvidia/test-model");
expect(payload.result.preferredInferenceApi).toBe("openai-completions");
expect(
payload.lines.some((line: string) => line.includes("[non-interactive] Provider: build")),
).toBe(true);
expect(payload.lines.every((line: string) => !line.includes("recovered from sandbox"))).toBe(
true,
);
});
});
Loading