diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 442dd9f827..e138e0ac81 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -930,6 +930,7 @@ export function validateOllamaModel( runCaptureImpl?: RunCaptureFn, isSparkImpl?: () => boolean, runCaptureExImpl?: RunCaptureExFn, + options: { allowToolsIncompatible?: boolean } = {}, ): ValidationResult { const capture = runCaptureImpl ?? runCapture; const captureEx = runCaptureExImpl ?? runCaptureEx; @@ -959,37 +960,46 @@ export function validateOllamaModel( if (parsed && typeof parsed.error === "string" && parsed.error.trim()) { const errText = parsed.error.trim(); if (/does not support tools/i.test(errText)) { - return { - ok: false, - message: - `Selected Ollama model '${model}' does not support tool calling, which ` + - `NemoClaw agents require. Run \`ollama show \` to inspect a ` + - `model's capabilities and pick one whose list includes 'tools'.`, - }; - } - // Ollama checks available RAM instead of total; false positive on DGX Spark - // unified-memory hosts where GPU and CPU share the same 128 GB pool. (#3251) - const memMatch = errText.match( - /model requires more system memory \(([0-9.]+)\s*GiB\) than is available \([0-9.]+\s*GiB\)/i, - ); - if (memMatch && sparkHost) { - const requiresGiB = parseFloat(memMatch[1]); - const freeOut = capture(["free", "-m"], { ignoreError: true }); - if (freeOut) { - const memLine = freeOut.split("\n").find((l: string) => l.includes("Mem:")); - if (memLine) { - const totalMB = parseInt(memLine.trim().split(/\s+/)[1], 10) || 0; - const totalGiB = totalMB / 1024; - if (totalGiB >= requiresGiB) { - return { ok: true }; + if (options.allowToolsIncompatible !== true) { + return { + ok: false, + message: + `Selected Ollama model '${model}' does not support tool calling, which ` + + `NemoClaw agents require. Run \`ollama show \` to inspect a ` + + `model's capabilities and pick one whose list includes 'tools'.`, + }; + } + // Override accepted — log and fall through to the Spark CPU-only + // runtime check below so it still enforces. (#4241) + console.warn( + ` ⚠ Ollama model '${model}' confirmed not to support tools; ` + + `continuing because the no-tools override was accepted.`, + ); + } else { + // Ollama checks available RAM instead of total; false positive on DGX Spark + // unified-memory hosts where GPU and CPU share the same 128 GB pool. (#3251) + const memMatch = errText.match( + /model requires more system memory \(([0-9.]+)\s*GiB\) than is available \([0-9.]+\s*GiB\)/i, + ); + if (memMatch && sparkHost) { + const requiresGiB = parseFloat(memMatch[1]); + const freeOut = capture(["free", "-m"], { ignoreError: true }); + if (freeOut) { + const memLine = freeOut.split("\n").find((l: string) => l.includes("Mem:")); + if (memLine) { + const totalMB = parseInt(memLine.trim().split(/\s+/)[1], 10) || 0; + const totalGiB = totalMB / 1024; + if (totalGiB >= requiresGiB) { + return { ok: true }; + } } } } + return { + ok: false, + message: `Selected Ollama model '${model}' failed the local probe: ${errText}`, + }; } - return { - ok: false, - message: `Selected Ollama model '${model}' failed the local probe: ${errText}`, - }; } } catch { /* ignored */ @@ -1008,6 +1018,29 @@ export function validateOllamaModel( return { ok: true }; } +// Helpers for threading the user's "use this no-tools Ollama model anyway" +// override (see #4241) through onboard validators so they don't loop the +// wizard back to model selection after the user already accepted. + +export function buildOllamaProbeOptions(allowToolsIncompatible: boolean): { + skipResponsesProbe: true; + requireChatCompletionsToolCalling: boolean; + allowHostDockerInternal: boolean; +} { + return { + skipResponsesProbe: true, + requireChatCompletionsToolCalling: !allowToolsIncompatible, + allowHostDockerInternal: getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, + }; +} + +export function validateOllamaModelWithToolsOverride( + model: string, + allowToolsIncompatible: boolean, +): ValidationResult { + return validateOllamaModel(model, undefined, undefined, undefined, { allowToolsIncompatible }); +} + // ─── Tools-capability probe (issue #2667) ───────────────────────── // // Ollama exposes a model's declared capabilities via /api/show. Tool calling diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index 0ef15457ff..50859142e1 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -688,7 +688,7 @@ function printToolsIncompatibleWarning(model: string): void { async function checkOllamaModelToolSupport( model: string, -): Promise<{ ok: boolean; message?: string }> { +): Promise<{ ok: boolean; message?: string; allowToolsIncompatible?: boolean }> { const caps = probeOllamaModelCapabilities(model); if (caps.supportsTools === true) { @@ -705,11 +705,15 @@ async function checkOllamaModelToolSupport( } // supportsTools === false — model is on disk but advertises no tools support. + // Every code path below that returns ok:true must also set + // allowToolsIncompatible:true so downstream validators (validateOllamaModel, + // probeChatCompletionsToolCalling via setupOllama / setupInference) don't + // reject the same model on the same condition — see issue #4241. printToolsIncompatibleWarning(model); if (isProxyAutoYes()) { console.log(" Continuing because --yes was passed."); - return { ok: true }; + return { ok: true, allowToolsIncompatible: true }; } if (isProxyNonInteractive()) { @@ -717,7 +721,7 @@ async function checkOllamaModelToolSupport( console.error( ` NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 set — proceeding with '${model}' despite missing 'tools'.`, ); - return { ok: true }; + return { ok: true, allowToolsIncompatible: true }; } console.error( " Re-run with NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 to override, or pick a tools-capable model.", @@ -729,10 +733,13 @@ async function checkOllamaModelToolSupport( if (!proceed) { return { ok: false, message: "Choose a tools-capable model." }; } - return { ok: true }; + return { ok: true, allowToolsIncompatible: true }; } -async function prepareOllamaModel(model, installedModels = []) { +async function prepareOllamaModel( + model, + installedModels: string[] = [], +): Promise<{ ok: boolean; message?: string; allowToolsIncompatible?: boolean }> { const alreadyInstalled = installedModels.includes(model); if (!alreadyInstalled) { console.log(` Pulling Ollama model: ${model}`); @@ -753,7 +760,11 @@ async function prepareOllamaModel(model, installedModels = []) { console.log(` Loading Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); - return validateOllamaModel(model); + const allowToolsIncompatible = capCheck.allowToolsIncompatible === true; + const result = validateOllamaModel(model, undefined, undefined, undefined, { + allowToolsIncompatible, + }); + return { ...result, allowToolsIncompatible }; } /** diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index eb9fb7c58c..50e8077bfc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4107,7 +4107,7 @@ const { readLiveInference, readRecordedProvider, readRecordedNimContainer, readR }); type OllamaModelSelectionOutcome = - | { outcome: "selected"; model: string } + | { outcome: "selected"; model: string; allowToolsIncompatible: boolean } | { outcome: "back-to-selection" }; // Pick an Ollama model, pull it if missing, and validate it via the local // proxy. Shared by the three Ollama provider branches (running, Windows-host @@ -4169,6 +4169,7 @@ async function selectAndValidateOllamaModel( console.log(""); continue; } + const allowToolsIncompatible = probe.allowToolsIncompatible === true; const validationBaseUrl = getLocalProviderValidationBaseUrl(provider); if (!validationBaseUrl) abortNonInteractive("Local Ollama validation URL could not be determined."); @@ -4179,12 +4180,7 @@ async function selectAndValidateOllamaModel( null, "Choose a different Ollama model or select Other.", null, - { - skipResponsesProbe: true, - requireChatCompletionsToolCalling: process.env.NEMOCLAW_OLLAMA_REQUIRE_TOOLS !== "0", - allowHostDockerInternal: - localInference.getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, - }, + localInference.buildOllamaProbeOptions(allowToolsIncompatible), ); if (validation.retry === "selection") return { outcome: "back-to-selection" }; if (!validation.ok) { @@ -4199,7 +4195,7 @@ async function selectAndValidateOllamaModel( ); } localInference.applyOllamaRuntimeContextWindow(selectedModel); - return { outcome: "selected", model: selectedModel }; + return { outcome: "selected", model: selectedModel, allowToolsIncompatible }; } } @@ -4216,6 +4212,7 @@ async function setupNim( hermesToolGateways: string[]; preferredInferenceApi: string | null; nimContainer: string | null; + allowToolsIncompatible: boolean; }> { step(3, 8, "Configuring inference provider"); @@ -4227,6 +4224,7 @@ async function setupNim( let hermesAuthMethod: HermesAuthMethod | null = null; let hermesToolGateways: string[] = []; let preferredInferenceApi: string | null = null; + let allowToolsIncompatible = false; // Detect local inference options. Bound curl with --connect-timeout/--max-time // so a half-open port or stalled listener cannot hang the onboard at step 3 @@ -5139,7 +5137,7 @@ async function setupNim( recoveredModel: recoveredFromSandbox ? recoveredModel : null, }); if (result.outcome === "back-to-selection") continue selectionLoop; - model = result.model; + ({ model, allowToolsIncompatible } = result); preferredInferenceApi = "openai-completions"; } break; @@ -5225,7 +5223,7 @@ async function setupNim( resetOllamaHostCache(); continue selectionLoop; } - model = result.model; + ({ model, allowToolsIncompatible } = result); preferredInferenceApi = "openai-completions"; } break; @@ -5267,7 +5265,7 @@ async function setupNim( recoveredModel: recoveredFromSandbox ? recoveredModel : null, }); if (result.outcome === "back-to-selection") continue selectionLoop; - model = result.model; + ({ model, allowToolsIncompatible } = result); preferredInferenceApi = "openai-completions"; } break; @@ -5425,6 +5423,7 @@ async function setupNim( hermesToolGateways, preferredInferenceApi, nimContainer, + allowToolsIncompatible, }; } @@ -5438,6 +5437,7 @@ async function setupInference( credentialEnv: string | null = null, hermesAuthMethod: HermesAuthMethod | string | null = null, hermesToolGateways: string[] = [], + options: { allowToolsIncompatible?: boolean } = {}, ): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> { step(4, 8, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); @@ -5742,7 +5742,7 @@ async function setupInference( if (await applyLocalInferenceRoute("ollama-local", model)) return { retry: "selection" }; console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); - const probe = validateOllamaModel(model); + const probe = localInference.validateOllamaModelWithToolsOverride(model, options.allowToolsIncompatible === true); if (!probe.ok) { console.error(` ${probe.message}`); process.exit(1); diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index 5fd788f6e0..5414e898a5 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -148,6 +148,7 @@ describe("handleProviderInferenceState", () => { "NVIDIA_API_KEY", null, [], + { allowToolsIncompatible: false }, ); expect(calls.deleteEnv).toHaveBeenCalledWith("NVIDIA_API_KEY"); expect(result).toMatchObject({ @@ -311,6 +312,7 @@ describe("handleProviderInferenceState", () => { "COMPATIBLE_API_KEY", null, [], + { allowToolsIncompatible: false }, ); }); @@ -358,4 +360,34 @@ describe("handleProviderInferenceState", () => { expect(calls.exit).toHaveBeenCalledWith(0); expect(calls.setupInference).not.toHaveBeenCalled(); }); + + // Regression: #4241. When the provider selection step accepted a no-tools + // Ollama model (the user answered "yes" to the override prompt or + // NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 was set), the same flag must reach + // setupInference so the second validateOllamaModel pass does not reject the + // model on the same condition and bounce the user back to model selection. + it("forwards allowToolsIncompatible from provider selection into setupInference (#4241)", async () => { + const setupNim = vi.fn(async () => ({ + ...baseSelection, + provider: "ollama-local", + model: "tinyllama:1.1b", + endpointUrl: "http://127.0.0.1:11434/v1", + credentialEnv: null, + allowToolsIncompatible: true, + })); + const { deps, calls } = createDeps({ setupNim }); + + await handleProviderInferenceState(baseOptions(deps)); + + expect(calls.setupInference).toHaveBeenCalledWith( + "my-assistant", + "tinyllama:1.1b", + "ollama-local", + "http://127.0.0.1:11434/v1", + null, + null, + [], + { allowToolsIncompatible: true }, + ); + }); }); diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index f84d682943..44d2cf5ed5 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -16,6 +16,7 @@ export interface ProviderSelectionResult { hermesToolGateways: string[]; preferredInferenceApi: string | null; nimContainer: string | null; + allowToolsIncompatible?: boolean; } export interface ProviderInferenceStateOptions { @@ -54,6 +55,7 @@ export interface ProviderInferenceStateOptions { credentialEnv: string | null, hermesAuthMethod: string | null, hermesToolGateways: string[], + options?: { allowToolsIncompatible?: boolean }, ): Promise; startRecordedStep( stepName: string, @@ -166,6 +168,7 @@ export async function handleProviderInferenceState({ let nimContainer = initial.nimContainer; const webSearchConfig = initial.webSearchConfig; let forceProviderSelection = initialForceProviderSelection; + let allowToolsIncompatible = false; while (true) { let forceInferenceSetup = false; @@ -225,6 +228,7 @@ export async function handleProviderInferenceState({ hermesToolGateways = selection.hermesToolGateways; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; + allowToolsIncompatible = selection.allowToolsIncompatible === true; shouldRecordProviderSelection = true; } @@ -277,6 +281,7 @@ export async function handleProviderInferenceState({ credentialEnv, hermesAuthMethod, hermesToolGateways, + { allowToolsIncompatible }, ), ); } finally { @@ -360,6 +365,7 @@ export async function handleProviderInferenceState({ credentialEnv, hermesAuthMethod, hermesToolGateways, + { allowToolsIncompatible }, ), ); } finally { diff --git a/test/ollama-tools-capability.test.ts b/test/ollama-tools-capability.test.ts index 6a9fe91faf..b9f1ecb588 100644 --- a/test/ollama-tools-capability.test.ts +++ b/test/ollama-tools-capability.test.ts @@ -31,6 +31,7 @@ interface LocalInferenceModule { capture?: CaptureFn, isSparkImpl?: () => boolean, captureExImpl?: (cmd: string[]) => { stdout: string; exitCode: number | null; timedOut: boolean }, + options?: { allowToolsIncompatible?: boolean }, ) => { ok: boolean; message?: string }; setResolvedOllamaHost: (host: string) => void; resetOllamaHostCache: () => void; @@ -40,7 +41,7 @@ interface LocalInferenceModule { interface OnboardOllamaProxyModule { checkOllamaModelToolSupport: ( model: string, - ) => Promise<{ ok: boolean; message?: string }>; + ) => Promise<{ ok: boolean; message?: string; allowToolsIncompatible?: boolean }>; } function loadLocalInference(): LocalInferenceModule { @@ -324,7 +325,7 @@ describe("checkOllamaModelToolSupport", () => { } }); - it("interactive yes → {ok:true}", async () => { + it("interactive yes → {ok:true, allowToolsIncompatible:true}", async () => { const h = loadProxyWithStubs(); h.setProbeResult({ source: "api", @@ -333,7 +334,9 @@ describe("checkOllamaModelToolSupport", () => { }); h.setPromptReply("y"); const out = await h.proxy.checkOllamaModelToolSupport("phi4"); - expect(out).toEqual({ ok: true }); + // The override flag is what downstream validators consume to skip the + // strict tools probe — without it onboard would loop back. See #4241. + expect(out).toEqual({ ok: true, allowToolsIncompatible: true }); // Warning banner was printed. expect(h.logs.some((l) => l.includes("does not advertise the 'tools' capability"))).toBe(true); // Prompt was actually shown. @@ -367,7 +370,7 @@ describe("checkOllamaModelToolSupport", () => { expect(h.errors.some((e) => e.includes("NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0"))).toBe(true); }); - it("non-interactive + NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 → {ok:true} after stderr warning", async () => { + it("non-interactive + NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0 → {ok:true, allowToolsIncompatible:true} after stderr warning", async () => { process.env.NEMOCLAW_NON_INTERACTIVE = "1"; process.env.NEMOCLAW_OLLAMA_REQUIRE_TOOLS = "0"; const h = loadProxyWithStubs(); @@ -378,6 +381,7 @@ describe("checkOllamaModelToolSupport", () => { }); const out = await h.proxy.checkOllamaModelToolSupport("phi4"); expect(out.ok).toBe(true); + expect(out.allowToolsIncompatible).toBe(true); // Stderr warning mentions the env-var override + model name. const matched = h.errors.some( (e) => e.includes("NEMOCLAW_OLLAMA_REQUIRE_TOOLS=0") && e.includes("phi4"), @@ -385,7 +389,7 @@ describe("checkOllamaModelToolSupport", () => { expect(matched).toBe(true); }); - it("NEMOCLAW_YES=1 → {ok:true} after note", async () => { + it("NEMOCLAW_YES=1 → {ok:true, allowToolsIncompatible:true} after note", async () => { process.env.NEMOCLAW_YES = "1"; const h = loadProxyWithStubs(); h.setProbeResult({ @@ -394,7 +398,7 @@ describe("checkOllamaModelToolSupport", () => { supportsTools: false, }); const out = await h.proxy.checkOllamaModelToolSupport("phi4"); - expect(out).toEqual({ ok: true }); + expect(out).toEqual({ ok: true, allowToolsIncompatible: true }); // Note about --yes is printed. expect(h.logs.some((l) => l.toLowerCase().includes("--yes"))).toBe(true); // Prompt should NOT have been shown. @@ -410,9 +414,215 @@ describe("checkOllamaModelToolSupport", () => { rawError: "connection refused", }); const out = await h.proxy.checkOllamaModelToolSupport("phi4"); + // Probe could not determine capabilities — no override needed because we + // don't know the model is incompatible. expect(out).toEqual({ ok: true }); // Informational note printed; no warning banner. expect(h.logs.some((l) => l.includes("Could not verify 'tools' capability"))).toBe(true); expect(h.logs.some((l) => l.includes("does not advertise the 'tools' capability"))).toBe(false); }); }); + +// ───────────────────────────────────────────────────────────────── +// Regression coverage for #4241: the no-tools override must propagate +// through `validateOllamaModel`, so a model that the user explicitly +// accepted does not get rejected again on the same "does not support +// tools" error during the second validation pass in setupInference. +// Without the override (`allowToolsIncompatible: false`/unset), the +// validator must still fail early so the wizard does not silently +// strand the user on an unusable model. +// ───────────────────────────────────────────────────────────────── + +describe("validateOllamaModel — no-tools override propagation (#4241)", () => { + let localInference: LocalInferenceModule; + + beforeEach(() => { + localInference = loadLocalInference(); + }); + + function captureExReturning(errText: string) { + const payload = JSON.stringify({ error: errText }); + return () => ({ stdout: payload, exitCode: 0, timedOut: false }); + } + + it("rejects a no-tools model when no override is set (back-to-selection path)", () => { + const { capture } = makeCapture([]); + const captureEx = captureExReturning( + "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + ); + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + capture, + () => false, + captureEx, + ); + expect(result.ok).toBe(false); + expect(result.message!).toContain("tinyllama"); + expect(result.message!.toLowerCase()).toContain("tools"); + }); + + it("rejects a no-tools model when allowToolsIncompatible is false (explicit no override)", () => { + const { capture } = makeCapture([]); + const captureEx = captureExReturning( + "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + ); + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + capture, + () => false, + captureEx, + { allowToolsIncompatible: false }, + ); + expect(result.ok).toBe(false); + expect(result.message!.toLowerCase()).toContain("tools"); + }); + + it("accepts a no-tools model when allowToolsIncompatible is true (override accepted upstream)", () => { + const { capture } = makeCapture([]); + const captureEx = captureExReturning( + "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + ); + const warned: string[] = []; + const warnSpy = vi.spyOn(console, "warn").mockImplementation((...args) => { + warned.push(args.map((a) => String(a)).join(" ")); + }); + try { + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + capture, + () => false, + captureEx, + { allowToolsIncompatible: true }, + ); + expect(result.ok).toBe(true); + // The earlier accept-the-override warning is restated rather than + // reverted into a hard validation failure that would loop selection. + expect(warned.some((l) => l.includes("no-tools override was accepted"))).toBe(true); + } finally { + warnSpy.mockRestore(); + } + }); + + it("still surfaces non-tools errors (e.g. memory) regardless of override", () => { + const { capture } = makeCapture([]); + const captureEx = captureExReturning( + "model requires more system memory (50 GiB) than is available (8 GiB)", + ); + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + capture, + () => false, + captureEx, + { allowToolsIncompatible: true }, + ); + expect(result.ok).toBe(false); + expect(result.message!).toContain("more system memory"); + }); + + // The override only suppresses the tools-capability rejection. The Spark + // CPU-only runtime check (probeOllamaRuntimeModelStatus) must still run + // after the warning fires and surface its own diagnostic — otherwise an + // accepted no-tools model on Spark could silently land on CPU. + it("Spark CPU-only check still rejects after override is accepted", () => { + const cpuOnlyApiPs = JSON.stringify({ + models: [{ name: "tinyllama:1.1b", model: "tinyllama:1.1b", size_vram: 0, processor: "100% CPU" }], + }); + const { capture } = makeCapture([{ match: /\/api\/ps/, output: cpuOnlyApiPs }]); + const captureEx = captureExReturning( + "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + ); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + capture, + () => true, + captureEx, + { allowToolsIncompatible: true }, + ); + expect(result.ok).toBe(false); + expect(result.message!.toLowerCase()).toContain("cpu"); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +// ───────────────────────────────────────────────────────────────── +// End-to-end propagation: when checkOllamaModelToolSupport accepts the +// override, the same model must clear the second validateOllamaModel +// call without looping back to model selection (the user-visible +// #4241 regression). Pre-fix this assertion fails because +// validateOllamaModel does not know about the override and re-rejects. +// ───────────────────────────────────────────────────────────────── + +describe("override propagation across checkOllamaModelToolSupport → validateOllamaModel (#4241)", () => { + it("user accepts override → later validateOllamaModel does NOT reject for the same tools-incompatible error", async () => { + const h = loadProxyWithStubs(); + h.setProbeResult({ + source: "api", + capabilities: ["completion"], + supportsTools: false, + }); + h.setPromptReply("y"); + + // (1) The capability check is what an onboarding session would run first. + const cap = await h.proxy.checkOllamaModelToolSupport("tinyllama:1.1b"); + expect(cap.ok).toBe(true); + expect(cap.allowToolsIncompatible).toBe(true); + + // (2) The same setupInference path runs validateOllamaModel(model) after + // `openshell inference set`. Pre-fix it returned ok:false on the same + // "does not support tools" condition and onboarding called + // process.exit(1). Threading `allowToolsIncompatible` keeps it ok:true. + const localInference = loadLocalInference(); + const captureEx = () => ({ + stdout: JSON.stringify({ + error: "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + }), + exitCode: 0, + timedOut: false, + }); + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + () => "", + () => false, + captureEx, + { allowToolsIncompatible: cap.allowToolsIncompatible === true }, + ); + expect(result.ok).toBe(true); + }); + + it("user declines override → both stages refuse and no later validation runs", async () => { + const h = loadProxyWithStubs(); + h.setProbeResult({ + source: "api", + capabilities: ["completion"], + supportsTools: false, + }); + h.setPromptReply("n"); + + const cap = await h.proxy.checkOllamaModelToolSupport("tinyllama:1.1b"); + expect(cap.ok).toBe(false); + // Override was never granted, so downstream validators must keep rejecting + // the same model. + expect(cap.allowToolsIncompatible).toBeUndefined(); + + const localInference = loadLocalInference(); + const captureEx = () => ({ + stdout: JSON.stringify({ + error: "registry.ollama.ai/library/tinyllama:1.1b does not support tools", + }), + exitCode: 0, + timedOut: false, + }); + const result = localInference.validateOllamaModel( + "tinyllama:1.1b", + () => "", + () => false, + captureEx, + { allowToolsIncompatible: cap.allowToolsIncompatible === true }, + ); + expect(result.ok).toBe(false); + }); +}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index a8513c73c3..cf16adef5c 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1306,6 +1306,7 @@ localInference.validateLocalProvider = () => ({ localInference.getLocalProviderBaseUrl = () => "http://host.openshell.internal:11435/v1"; localInference.getOllamaWarmupCommand = () => ["true"]; localInference.validateOllamaModel = () => ({ ok: true }); +localInference.validateOllamaModelWithToolsOverride = () => ({ ok: true }); proxy.ensureOllamaAuthProxy = () => { proxyCalls.push("ensure"); };