Skip to content
Merged
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
8 changes: 4 additions & 4 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 @@ -3978,7 +3979,6 @@ async function setupNim(
: null;
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 Expand Up @@ -4033,9 +4033,9 @@ async function setupNim(
isWindowsHostOllama,
windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported,
hermesProviderAvailable,
readRecordedProvider,
readRecordedNimContainer,
readRecordedModel,
readRecordedProvider: recoverProvider ? readRecordedProvider : () => null,
readRecordedNimContainer: recoverProvider ? readRecordedNimContainer : () => null,
readRecordedModel: recoverProvider ? readRecordedModel : () => null,
});
if (providerSelection.kind === "failure") {
reportProviderSelectionFailure({
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
33 changes: 32 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,36 @@ 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("does not use resume shortcuts when fresh is also set", async () => {
const session = createSession({ provider: "ollama-local", model: "llama3.1" });
session.steps.provider_selection.status = "complete";
const { deps, calls } = createDeps({ isInferenceRouteReady: vi.fn(() => true) });

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

expect(calls.recoverProvider).not.toHaveBeenCalled();
expect(calls.skipped).not.toHaveBeenCalledWith("provider_selection", expect.anything());
expect(calls.setupNim).toHaveBeenCalledWith({ type: "nvidia" }, "dcode-station", null, false);
expect(calls.setupInference).toHaveBeenCalled();
});

it("clears non-NVIDIA provider credentials when inference setup fails", async () => {
const setupNim = vi.fn(async () => ({
...baseSelection,
Expand Down
16 changes: 12 additions & 4 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.
session,
gpu,
sandboxName,
Expand Down Expand Up @@ -195,14 +202,15 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
let forceProviderSelection = initialForceProviderSelection;
let allowToolsIncompatible = false;
let skipHostInferenceSmoke = false;
const effectiveResume = resume && !fresh;
const stateResults: OnboardStateTransitionResult[] = [];
const retryStateResults: OnboardStateTransitionResult[] = [];

while (true) {
let forceInferenceSetup = false;
const resumeProviderSelection =
!forceProviderSelection &&
resume &&
effectiveResume &&
session?.steps?.provider_selection?.status === "complete" &&
typeof provider === "string" &&
typeof model === "string";
Expand Down Expand Up @@ -246,7 +254,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 Expand Up @@ -292,7 +300,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
!needsBedrockRuntimeAdapter &&
!forceProviderSelection &&
!forceInferenceSetup &&
resume &&
effectiveResume &&
deps.isInferenceRouteReady(provider, model);
if (resumeInference) {
if (provider === constants.hermesProviderName) {
Expand Down
145 changes: 145 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,148 @@ 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"));
const credentialsPath = JSON.stringify(
path.join(repoRoot, "dist", "lib", "credentials", "store.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 credentials = require(${credentialsPath});
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 prompts = [];
credentials.prompt = async (message) => {
prompts.push(message);
return "";
};
credentials.ensureApiKey = async () => {};

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 nonInteractive = await setupNim(null, "dcode-station", null, false);
process.env.NEMOCLAW_PROVIDER = "openai";
process.env.OPENAI_API_KEY = "sk-test";
process.env.NEMOCLAW_MODEL = "gpt-5.4";
const explicitProvider = await setupNim(null, "dcode-station", null, false);
delete process.env.NEMOCLAW_PROVIDER;
delete process.env.OPENAI_API_KEY;
process.env.NEMOCLAW_MODEL = "nvidia/test-model";
__setNonInteractive(false);
const interactive = await setupNim(null, "dcode-station", null, false);
originalLog(JSON.stringify({ nonInteractive, explicitProvider, interactive, prompts, 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",
},
});

expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout.trim());
expect(payload.nonInteractive.provider).toBe("nvidia-prod");
expect(payload.nonInteractive.model).toBe("nvidia/test-model");
expect(payload.nonInteractive.preferredInferenceApi).toBe("openai-completions");
expect(payload.explicitProvider.provider).toBe("openai-api");
expect(payload.explicitProvider.model).toBe("gpt-5.4");
expect(payload.interactive.provider).toBe("nvidia-prod");
expect(payload.interactive.preferredInferenceApi).toBe("openai-completions");
expect(payload.prompts[0]).toMatch(/^ Choose \[\d+\]: $/);
expect(
payload.lines.some((line: string) => line.includes("Select your inference provider")),
).toBe(true);
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