fix: simplify ollama onboarding (#67005)

* feat(ollama): split interactive cloud and local setup

* test(ollama): cover cloud onboarding flow

* docs(ollama): simplify provider setup docs

* docs(onboarding): update ollama wizard copy

* fix(ollama): restore web search auth helper

* fix(ollama): harden setup auth and ssrf handling

* fix(ollama): address review regressions

* fix(ollama): scope ssrf hardening to ollama

* feat(ollama): add hybrid onboarding mode

* fix(ollama): tighten cloud credential setup

* refactor(ollama): distill host-backed setup modes

* fix(ollama): preserve cloud api key in config

* fix: simplify ollama onboarding (#67005)
This commit is contained in:
Ayaan Zaidi
2026-04-15 19:06:21 +05:30
committed by GitHub
parent 20cce166ef
commit 33154ce745
15 changed files with 566 additions and 229 deletions
+1
View File
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.
- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.
- Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung.
- Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.
## 2026.4.14
+37 -35
View File
@@ -8,7 +8,7 @@ title: "Ollama"
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supports streaming and tool calling, and can auto-discover local Ollama models when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
OpenClaw integrates with Ollama's native API (`/api/chat`) for hosted cloud models and local/self-hosted Ollama servers. You can use Ollama in three modes: `Cloud + Local` through a reachable Ollama host, `Cloud only` against `https://ollama.com`, or `Local only` against a reachable Ollama host.
<Warning>
**Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`).
@@ -20,7 +20,7 @@ Choose your preferred setup method and mode.
<Tabs>
<Tab title="Onboarding (recommended)">
**Best for:** fastest path to a working Ollama setup with automatic model discovery.
**Best for:** fastest path to a working Ollama cloud or local setup.
<Steps>
<Step title="Run onboarding">
@@ -31,13 +31,12 @@ Choose your preferred setup method and mode.
Select **Ollama** from the provider list.
</Step>
<Step title="Choose your mode">
- **Cloud + Local** — cloud-hosted models and local models together
- **Local** — local models only
If you choose **Cloud + Local** and are not signed in to ollama.com, onboarding opens a browser sign-in flow.
- **Cloud + Local** — local Ollama host plus cloud models routed through that host
- **Cloud only** — hosted Ollama models via `https://ollama.com`
- **Local only** — local models only
</Step>
<Step title="Select a model">
Onboarding discovers available models and suggests defaults. It auto-pulls the selected model if it is not available locally.
`Cloud only` prompts for `OLLAMA_API_KEY` and suggests hosted cloud defaults. `Cloud + Local` and `Local only` ask for an Ollama base URL, discover available models, and auto-pull the selected local model if it is not available yet. `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
</Step>
<Step title="Verify the model is available">
```bash
@@ -67,13 +66,15 @@ Choose your preferred setup method and mode.
</Tab>
<Tab title="Manual setup">
**Best for:** full control over installation, model pulls, and config.
**Best for:** full control over cloud or local setup.
<Steps>
<Step title="Install Ollama">
Download from [ollama.com/download](https://ollama.com/download).
<Step title="Choose cloud or local">
- **Cloud + Local**: install Ollama, sign in with `ollama signin`, and route cloud requests through that host
- **Cloud only**: use `https://ollama.com` with an `OLLAMA_API_KEY`
- **Local only**: install Ollama from [ollama.com/download](https://ollama.com/download)
</Step>
<Step title="Pull a local model">
<Step title="Pull a local model (local only)">
```bash
ollama pull gemma4
# or
@@ -82,22 +83,18 @@ Choose your preferred setup method and mode.
ollama pull llama3.3
```
</Step>
<Step title="Sign in for cloud models (optional)">
If you want cloud models too:
```bash
ollama signin
```
</Step>
<Step title="Enable Ollama for OpenClaw">
Set any value for the API key (Ollama does not require a real key):
For `Cloud only`, use your real `OLLAMA_API_KEY`. For host-backed setups, any placeholder value works:
```bash
# Set environment variable
# Cloud
export OLLAMA_API_KEY="your-ollama-api-key"
# Local-only
export OLLAMA_API_KEY="ollama-local"
# Or configure in your config file
openclaw config set models.providers.ollama.apiKey "ollama-local"
openclaw config set models.providers.ollama.apiKey "OLLAMA_API_KEY"
```
</Step>
<Step title="Inspect and set your model">
@@ -127,18 +124,23 @@ Choose your preferred setup method and mode.
<Tabs>
<Tab title="Cloud + Local">
Cloud models let you run cloud-hosted models alongside your local models. Examples include `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` -- these do **not** require a local `ollama pull`.
`Cloud + Local` uses a reachable Ollama host as the control point for both local and cloud models. This is Ollama's preferred hybrid flow.
Select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
Use **Cloud + Local** during setup. OpenClaw prompts for the Ollama base URL, discovers local models from that host, and checks whether the host is signed in for cloud access with `ollama signin`. When the host is signed in, OpenClaw also suggests hosted cloud defaults such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud`.
You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
If the host is not signed in yet, OpenClaw keeps the setup local-only until you run `ollama signin`.
OpenClaw currently suggests these cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`.
</Tab>
<Tab title="Cloud only">
`Cloud only` runs against Ollama's hosted API at `https://ollama.com`.
Use **Cloud only** during setup. OpenClaw prompts for `OLLAMA_API_KEY`, sets `baseUrl: "https://ollama.com"`, and seeds the hosted cloud model list. This path does **not** require a local Ollama server or `ollama signin`.
</Tab>
<Tab title="Local only">
In local-only mode, OpenClaw discovers models from the local Ollama instance. No cloud sign-in is needed.
In local-only mode, OpenClaw discovers models from the configured Ollama instance. This path is for local or self-hosted Ollama servers.
OpenClaw currently suggests `gemma4` as the local default.
@@ -182,7 +184,7 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y
<Tabs>
<Tab title="Basic (implicit discovery)">
The simplest way to enable Ollama is via environment variable:
The simplest local-only enablement path is via environment variable:
```bash
export OLLAMA_API_KEY="ollama-local"
@@ -195,25 +197,25 @@ If you set `models.providers.ollama` explicitly, auto-discovery is skipped and y
</Tab>
<Tab title="Explicit (manual models)">
Use explicit config when Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions.
Use explicit config when you want hosted cloud setup, Ollama runs on another host/port, you want to force specific context windows or model lists, or you want fully manual model definitions.
```json5
{
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434",
apiKey: "ollama-local",
baseUrl: "https://ollama.com",
apiKey: "OLLAMA_API_KEY",
api: "ollama",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
id: "kimi-k2.5:cloud",
name: "kimi-k2.5:cloud",
reasoning: false,
input: ["text"],
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 8192 * 10
contextWindow: 128000,
maxTokens: 8192
}
]
}
+1 -1
View File
@@ -40,7 +40,7 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- Sets `agents.defaults.model` to `openai/gpt-5.4` when model is unset, `openai/*`, or `openai-codex/*`.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog.
- **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed.
- **Ollama**: offers **Cloud + Local**, **Cloud only**, or **Local only** first. `Cloud only` prompts for `OLLAMA_API_KEY` and uses `https://ollama.com`; the host-backed modes prompt for the Ollama base URL, discover available models, and auto-pull the selected local model when needed; `Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
- More detail: [Ollama](/providers/ollama)
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
+4 -2
View File
@@ -181,8 +181,10 @@ What you set:
More detail: [Synthetic](/providers/synthetic).
</Accordion>
<Accordion title="Ollama (Cloud and local open models)">
Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode.
Discovers available models and suggests defaults.
Prompts for `Cloud + Local`, `Cloud only`, or `Local only` first.
`Cloud only` uses `OLLAMA_API_KEY` with `https://ollama.com`.
The host-backed modes prompt for base URL (default `http://127.0.0.1:11434`), discover available models, and suggest defaults.
`Cloud + Local` also checks whether that Ollama host is signed in for cloud access.
More detail: [Ollama](/providers/ollama).
</Accordion>
<Accordion title="Moonshot and Kimi Coding">
+5 -2
View File
@@ -4,6 +4,7 @@ import plugin from "./index.js";
const promptAndConfigureOllamaMock = vi.hoisted(() =>
vi.fn(async () => ({
credential: "ollama-local",
config: {
models: {
providers: {
@@ -81,9 +82,11 @@ describe("ollama plugin", () => {
expect(promptAndConfigureOllamaMock).toHaveBeenCalledWith({
cfg: {},
env: undefined,
opts: undefined,
prompter: {},
isRemote: false,
openUrl: expect.any(Function),
secretInputMode: undefined,
allowSecretRefPrompt: undefined,
});
expect(result.configPatch).toEqual({
models: {
+17 -10
View File
@@ -6,6 +6,7 @@ import {
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
import {
OPENAI_COMPATIBLE_REPLAY_HOOKS,
type ModelProviderConfig,
@@ -57,9 +58,7 @@ function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: OllamaProviderLikeConfig | undefined,
): boolean {
function hasMeaningfulExplicitOllamaConfig(providerConfig?: OllamaProviderLikeConfig): boolean {
if (!providerConfig) {
return false;
}
@@ -116,19 +115,27 @@ export default definePluginEntry({
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const result = await promptAndConfigureOllama({
cfg: ctx.config,
env: ctx.env,
opts: ctx.opts as Record<string, unknown> | undefined,
prompter: ctx.prompter,
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
secretInputMode: ctx.secretInputMode,
allowSecretRefPrompt: ctx.allowSecretRefPrompt,
});
return {
profiles: [
{
profileId: "ollama:default",
credential: {
type: "api_key",
provider: PROVIDER_ID,
key: DEFAULT_API_KEY,
},
credential: buildApiKeyCredential(
PROVIDER_ID,
result.credential,
undefined,
result.credentialMode
? {
secretInputMode: result.credentialMode,
config: ctx.config,
}
: undefined,
),
},
],
configPatch: result.config,
+1
View File
@@ -1,4 +1,5 @@
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
@@ -4,12 +4,31 @@ import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
describe("buildOllamaBaseUrlSsrFPolicy", () => {
it("pins requests to the configured Ollama hostname for HTTP(S) URLs", () => {
expect(buildOllamaBaseUrlSsrFPolicy("http://127.0.0.1:11434")).toEqual({
allowedHostnames: ["127.0.0.1"],
hostnameAllowlist: ["127.0.0.1"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("http://192.168.1.10:11434")).toEqual({
hostnameAllowlist: ["192.168.1.10"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.example.com/v1")).toEqual({
allowedHostnames: ["ollama.example.com"],
hostnameAllowlist: ["ollama.example.com"],
allowPrivateNetwork: true,
});
});
it("opts into private-network access for explicit Ollama hosts", () => {
expect(buildOllamaBaseUrlSsrFPolicy("http://localhost:11434")).toEqual({
hostnameAllowlist: ["localhost"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("http://[fd00::1]:11434")).toEqual({
hostnameAllowlist: ["[fd00::1]"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.local:11434")).toEqual({
hostnameAllowlist: ["ollama.local"],
allowPrivateNetwork: true,
});
});
@@ -17,5 +36,6 @@ describe("buildOllamaBaseUrlSsrFPolicy", () => {
expect(buildOllamaBaseUrlSsrFPolicy("")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("ftp://ollama.example.com")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("not-a-url")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("http://metadata.google.internal")).toBeUndefined();
});
});
+5 -1
View File
@@ -31,6 +31,7 @@ export type OllamaModelWithContext = OllamaTagModel & {
const OLLAMA_SHOW_CONCURRENCY = 8;
const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256;
const ollamaModelShowInfoCache = new Map<string, Promise<OllamaModelShowInfo>>();
const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
const trimmed = baseUrl.trim();
@@ -42,9 +43,12 @@ export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
if (OLLAMA_ALWAYS_BLOCKED_HOSTNAMES.has(parsed.hostname)) {
return undefined;
}
return {
allowedHostnames: [parsed.hostname],
hostnameAllowlist: [parsed.hostname],
allowPrivateNetwork: true,
};
} catch {
return undefined;
+180 -70
View File
@@ -2,6 +2,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
import { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js";
import {
configureOllamaNonInteractive,
ensureOllamaModelPulled,
@@ -9,18 +10,21 @@ import {
} from "./setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../../../src/agents/auth-profiles.js", () => ({
upsertAuthProfileWithLock,
}));
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
upsertAuthProfileWithLock,
};
});
function createOllamaFetchMock(params: {
tags?: string[];
show?: Record<string, number | undefined>;
meResponses?: Response[];
pullResponse?: Response;
tagsError?: Error;
meResponse?: Response;
}) {
const meResponses = [...(params.meResponses ?? [])];
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
@@ -37,7 +41,7 @@ function createOllamaFetchMock(params: {
: jsonResponse({});
}
if (url.endsWith("/api/me")) {
return meResponses.shift() ?? jsonResponse({ username: "testuser" });
return params.meResponse ?? jsonResponse({});
}
if (url.endsWith("/api/pull")) {
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
@@ -46,28 +50,29 @@ function createOllamaFetchMock(params: {
});
}
function createModePrompter(
mode: "local" | "remote",
params?: { confirm?: boolean },
): WizardPrompter {
function createLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("local-only"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce(mode),
...(params?.confirm !== undefined
? { confirm: vi.fn().mockResolvedValueOnce(params.confirm) }
: {}),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createSignedOutRemoteFetchMock() {
return createOllamaFetchMock({
tags: ["llama3:8b"],
meResponses: [
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
jsonResponse({ username: "testuser" }),
],
});
function createCloudPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-only"),
confirm: vi.fn().mockResolvedValueOnce(false),
text: vi.fn().mockResolvedValueOnce("test-ollama-key"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createCloudLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-local"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createDefaultOllamaConfig(primary: string) {
@@ -89,10 +94,11 @@ describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
upsertAuthProfileWithLock.mockClear();
resetOllamaModelShowInfoCacheForTest();
});
it("puts suggested local model first in local mode", async () => {
const prompter = createModePrompter("local");
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
@@ -100,33 +106,73 @@ describe("ollama setup", () => {
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("gemma4");
});
it("puts suggested cloud model first in remote mode", async () => {
const prompter = createModePrompter("remote");
it("puts suggested cloud model first in cloud mode", async () => {
const prompter = createCloudPrompter();
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
expect(modelIds?.[0]).toBe("kimi-k2.5:cloud");
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("https://ollama.com");
expect(result.config.models?.providers?.ollama?.apiKey).toBe("test-ollama-key");
expect(result.credential).toBe("test-ollama-key");
});
it("uses generic token flags for cloud-only setup", async () => {
const prompter = createCloudPrompter();
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
token: "generic-ollama-key",
tokenProvider: "ollama",
},
prompter,
allowSecretRefPrompt: false,
});
expect(result.credential).toBe("generic-ollama-key");
expect(prompter.text).not.toHaveBeenCalled();
});
it("puts hybrid cloud model suggestions after the local default when signed in", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponse: jsonResponse({ user: "signed-in" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("kimi-k2.5:cloud");
expect(modelIds).toEqual([
"gemma4",
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"llama3:8b",
]);
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434");
expect(result.credential).toBe("ollama-local");
});
it("mode selection affects model ordering (local)", async () => {
const prompter = createModePrompter("local");
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "gemma4"] });
vi.stubGlobal("fetch", fetchMock);
@@ -134,8 +180,6 @@ describe("ollama setup", () => {
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
@@ -143,31 +187,39 @@ describe("ollama setup", () => {
expect(modelIds).toContain("llama3:8b");
});
it("cloud+local mode triggers /api/me check and opens sign-in URL", async () => {
const prompter = createModePrompter("remote", { confirm: true });
const fetchMock = createSignedOutRemoteFetchMock();
const openUrl = vi.fn(async () => undefined);
it("cloud mode does not hit local Ollama endpoints", async () => {
const prompter = createCloudPrompter();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: false, openUrl });
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
expect(openUrl).toHaveBeenCalledWith("https://ollama.com/signin");
expect(prompter.confirm).toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("cloud+local mode does not open browser in remote environment", async () => {
const prompter = createModePrompter("remote", { confirm: true });
const fetchMock = createSignedOutRemoteFetchMock();
const openUrl = vi.fn(async () => undefined);
vi.stubGlobal("fetch", fetchMock);
it("rejects the local marker during cloud-only setup", async () => {
const prompter = createCloudPrompter();
await promptAndConfigureOllama({ cfg: {}, prompter, isRemote: true, openUrl });
expect(openUrl).not.toHaveBeenCalled();
await expect(
promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
ollamaApiKey: "ollama-local",
},
prompter,
allowSecretRefPrompt: false,
}),
).rejects.toThrow("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
});
it("local mode does not trigger cloud auth", async () => {
const prompter = createModePrompter("local");
it("local mode only hits local model discovery endpoints", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
@@ -175,8 +227,6 @@ describe("ollama setup", () => {
await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -186,35 +236,97 @@ describe("ollama setup", () => {
);
});
it("suggested models appear first in model list (cloud+local)", async () => {
it("asks for Ollama mode before cloud api key", async () => {
const events: string[] = [];
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
select: vi.fn(async () => {
events.push("select");
return "cloud-only";
}),
confirm: vi.fn(async () => false),
text: vi.fn(async () => {
events.push("text");
return "test-ollama-key";
}),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
expect(events).toEqual(["select", "text"]);
});
it("shows cloud-mode unreachable guidance when the host is down", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") });
vi.stubGlobal("fetch", fetchMock);
await expect(
promptAndConfigureOllama({
cfg: {},
prompter,
}),
).rejects.toThrow("Ollama not reachable");
expect(prompter.note).toHaveBeenCalledWith(
[
"Ollama could not be reached at http://127.0.0.1:11434.",
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup.",
].join("\n"),
"Ollama",
);
});
it("cloud + local mode falls back to local models when ollama signin is missing", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b", "gemma4", "deepseek-r1:14b"],
tags: ["llama3:8b"],
meResponse: new Response(JSON.stringify({ signin_url: "https://ollama.com/signin" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
expect(result.config.models?.providers?.ollama?.models?.map((m) => m.id)).toEqual([
"gemma4",
"llama3:8b",
]);
expect(prompter.note).toHaveBeenCalledWith(
[
"Cloud models on this Ollama host need `ollama signin`.",
"https://ollama.com/signin",
"",
"Continuing with local models only for now.",
].join("\n"),
"Ollama Cloud + Local",
);
});
it("cloud mode seeds the hosted cloud model list", async () => {
const prompter = createCloudPrompter();
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const models = result.config.models?.providers?.ollama?.models;
const modelIds = models?.map((m) => m.id);
expect(modelIds).toEqual([
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"llama3:8b",
"gemma4",
"deepseek-r1:14b",
]);
expect(modelIds).toEqual(["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"]);
expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([
"text",
"image",
@@ -224,7 +336,7 @@ describe("ollama setup", () => {
it("uses /api/show context windows when building Ollama model configs", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local"),
select: vi.fn().mockResolvedValueOnce("local-only"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
@@ -237,8 +349,6 @@ describe("ollama setup", () => {
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
const model = result.config.models?.providers?.ollama?.models?.find(
(m) => m.id === "llama3:8b",
+229 -104
View File
@@ -1,6 +1,17 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { upsertAuthProfileWithLock } from "openclaw/plugin-sdk/provider-auth";
import type {
OpenClawConfig,
SecretInput,
SecretInputMode,
} from "openclaw/plugin-sdk/provider-auth";
import {
ensureApiKeyFromOptionEnvOrPrompt,
isNonSecretApiKeyMarker,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup";
@@ -9,7 +20,11 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL } from "./defaults.js";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
} from "./defaults.js";
import {
buildOllamaBaseUrlSsrFPolicy,
buildOllamaModelDefinition,
@@ -23,15 +38,15 @@ const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"];
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
type OllamaMode = "remote" | "local";
type OllamaSetupOptions = {
customBaseUrl?: string;
customModelId?: string;
};
type OllamaCloudAuthResult = {
signedIn: boolean;
signinUrl?: string;
type OllamaSetupResult = {
config: OpenClawConfig;
credential: SecretInput;
credentialMode?: SecretInputMode;
};
type ProviderConfig = {
@@ -40,6 +55,41 @@ type ProviderConfig = {
models: ReturnType<typeof buildOllamaModelDefinition>[];
};
type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only";
type HostBackedOllamaInteractiveMode = Exclude<OllamaInteractiveMode, "cloud-only">;
const HOST_BACKED_OLLAMA_MODE_CONFIG: Record<
HostBackedOllamaInteractiveMode,
{ includeCloudModels: boolean; noteTitle: string }
> = {
"cloud-local": {
includeCloudModels: true,
noteTitle: "Ollama Cloud + Local",
},
"local-only": {
includeCloudModels: false,
noteTitle: "Ollama",
},
};
function buildOllamaUnreachableLines(baseUrl: string): string[] {
return [
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup.",
];
}
function buildOllamaCloudSigninLines(signinUrl?: string): string[] {
return [
"Cloud models on this Ollama host need `ollama signin`.",
signinUrl ?? "Run `ollama signin` on the configured Ollama host.",
"",
"Continuing with local models only for now.",
];
}
function normalizeOllamaModelName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
@@ -68,7 +118,9 @@ function formatOllamaPullStatus(status: string): { text: string; hidePercent: bo
return { text: trimmed, hidePercent: false };
}
export async function checkOllamaCloudAuth(baseUrl: string): Promise<OllamaCloudAuthResult> {
export async function checkOllamaCloudAuth(
baseUrl: string,
): Promise<{ signedIn: boolean; signinUrl?: string }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
@@ -246,6 +298,51 @@ async function pullOllamaModelNonInteractive(
return true;
}
async function promptForOllamaCloudCredential(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<{ credential: SecretInput; credentialMode?: SecretInputMode }> {
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
await ensureApiKeyFromOptionEnvOrPrompt({
token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token),
tokenProvider: optionToken
? "ollama"
: normalizeOptionalSecretInput(params.opts?.tokenProvider),
secretInputMode:
params.allowSecretRefPrompt === false
? (params.secretInputMode ?? "plaintext")
: params.secretInputMode,
config: params.cfg,
env: params.env,
expectedProviders: ["ollama"],
provider: "ollama",
envLabel: "OLLAMA_API_KEY",
promptMessage: "Ollama API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey, mode) => {
captured.credential = apiKey;
captured.credentialMode = mode;
},
});
if (!captured.credential) {
throw new Error("Missing Ollama API key input.");
}
if (
typeof captured.credential === "string" &&
isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false })
) {
throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
}
return { credential: captured.credential, credentialMode: captured.credentialMode };
}
function buildOllamaModelsConfig(
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
@@ -260,11 +357,27 @@ function buildOllamaModelsConfig(
});
}
function mergeUniqueModelNames(...groups: string[][]): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const group of groups) {
for (const name of group) {
if (seen.has(name)) {
continue;
}
seen.add(name);
merged.push(name);
}
}
return merged;
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
apiKey: SecretInput = "OLLAMA_API_KEY",
): OpenClawConfig {
return {
...cfg,
@@ -276,8 +389,7 @@ function applyOllamaProviderConfig(
ollama: {
baseUrl,
api: "ollama",
// pragma: allowlist secret
apiKey: "OLLAMA_API_KEY",
apiKey,
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName),
},
},
@@ -293,6 +405,74 @@ async function storeOllamaCredential(agentDir?: string): Promise<void> {
});
}
async function promptForOllamaBaseUrl(prompter: WizardPrompter): Promise<string> {
const baseUrlRaw = await prompter.text({
message: "Ollama base URL",
initialValue: OLLAMA_DEFAULT_BASE_URL,
placeholder: OLLAMA_DEFAULT_BASE_URL,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return resolveOllamaApiBase((baseUrlRaw ?? "").trim().replace(/\/+$/, ""));
}
async function resolveHostBackedSuggestedModelNames(params: {
mode: HostBackedOllamaInteractiveMode;
baseUrl: string;
prompter: WizardPrompter;
}): Promise<string[]> {
const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode];
if (!modeConfig.includeCloudModels) {
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
const auth = await checkOllamaCloudAuth(params.baseUrl);
if (auth.signedIn) {
return mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, OLLAMA_SUGGESTED_MODELS_CLOUD);
}
await params.prompter.note(
buildOllamaCloudSigninLines(auth.signinUrl).join("\n"),
modeConfig.noteTitle,
);
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
async function promptAndConfigureHostBackedOllama(params: {
cfg: OpenClawConfig;
mode: HostBackedOllamaInteractiveMode;
prompter: WizardPrompter;
}): Promise<OllamaSetupResult> {
const baseUrl = await promptForOllamaBaseUrl(params.prompter);
const { reachable, models } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama");
throw new WizardCancelledError("Ollama not reachable");
}
const enrichedModels = await enrichOllamaModelsWithContext(
baseUrl,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
);
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const discoveredModelNames = models.map((model) => model.name);
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
mode: params.mode,
baseUrl,
prompter: params.prompter,
});
return {
credential: "ollama-local",
config: applyOllamaProviderConfig(
params.cfg,
baseUrl,
mergeUniqueModelNames(suggestedModelNames, discoveredModelNames),
discoveredModelsByName,
),
};
}
export async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
@@ -317,100 +497,50 @@ export async function buildOllamaProvider(
export async function promptAndConfigureOllama(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
}): Promise<{ config: OpenClawConfig }> {
const baseUrlRaw = await params.prompter.text({
message: "Ollama base URL",
initialValue: OLLAMA_DEFAULT_BASE_URL,
placeholder: OLLAMA_DEFAULT_BASE_URL,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const baseUrl = resolveOllamaApiBase((baseUrlRaw ?? "").trim().replace(/\/+$/, ""));
const { reachable, models } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(
[
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup.",
].join("\n"),
"Ollama",
);
throw new WizardCancelledError("Ollama not reachable");
}
const enrichedModels = await enrichOllamaModelsWithContext(
baseUrl,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
);
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const modelNames = models.map((model) => model.name);
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<OllamaSetupResult> {
const mode = (await params.prompter.select({
message: "Ollama mode",
options: [
{ value: "remote", label: "Cloud + Local", hint: "Cloud models + local models" },
{ value: "local", label: "Local", hint: "Local models only" },
{
value: "cloud-local",
label: "Cloud + Local",
hint: "Route cloud and local models through your Ollama host",
},
{ value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" },
{ value: "local-only", label: "Local only", hint: "Local models only" },
],
})) as OllamaMode;
let cloudAuthVerified = false;
if (mode === "remote") {
const authResult = await checkOllamaCloudAuth(baseUrl);
if (!authResult.signedIn) {
if (authResult.signinUrl) {
if (!params.isRemote) {
await params.openUrl(authResult.signinUrl);
}
await params.prompter.note(
["Run `ollama signin`:", authResult.signinUrl].join("\n"),
"Ollama Sign-In",
);
const confirmed = await params.prompter.confirm({ message: "Have you signed in?" });
if (!confirmed) {
throw new WizardCancelledError("Ollama sign-in cancelled");
}
if (!(await checkOllamaCloudAuth(baseUrl)).signedIn) {
throw new WizardCancelledError("Ollama sign-in required");
}
cloudAuthVerified = true;
} else {
await params.prompter.note(
[
"Could not verify `ollama signin`.",
"Cloud models may not work until you sign in at https://ollama.com.",
].join("\n"),
"Ollama Sign-In",
);
if (!(await params.prompter.confirm({ message: "Continue without sign-in?" }))) {
throw new WizardCancelledError("Ollama sign-in could not be verified");
}
}
} else {
cloudAuthVerified = true;
}
})) as OllamaInteractiveMode;
if (mode === "cloud-only") {
const { credential, credentialMode } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
});
return {
credential,
credentialMode,
config: applyOllamaProviderConfig(
params.cfg,
OLLAMA_CLOUD_BASE_URL,
OLLAMA_SUGGESTED_MODELS_CLOUD,
undefined,
credential,
),
};
}
const suggestedModels =
mode === "local" || !cloudAuthVerified
? OLLAMA_SUGGESTED_MODELS_LOCAL
: OLLAMA_SUGGESTED_MODELS_CLOUD;
const orderedModelNames = [
...suggestedModels,
...modelNames.filter((name) => !suggestedModels.includes(name)),
];
return {
config: applyOllamaProviderConfig(
params.cfg,
baseUrl,
orderedModelNames,
discoveredModelsByName,
),
};
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
});
}
export async function configureOllamaNonInteractive(params: {
@@ -426,12 +556,7 @@ export async function configureOllamaNonInteractive(params: {
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
if (!reachable) {
params.runtime.error(
[
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
].join("\n"),
);
params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
@@ -39,6 +39,23 @@ function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenCl
};
}
function createOllamaConfigWithWebSearchBaseUrl(baseUrl: string): OpenClawConfig {
return {
...createOllamaConfig(),
plugins: {
entries: {
ollama: {
config: {
webSearch: {
baseUrl,
},
},
},
},
},
};
}
function createSetupNotes() {
const notes: Array<{ title?: string; message: string }> = [];
return {
@@ -90,6 +107,24 @@ describe("ollama web search provider", () => {
).toBe("http://ollama.local:11434");
});
it("prefers the plugin web search base URL over the model provider host", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfigWithWebSearchBaseUrl("http://localhost:11434/v1"),
),
).toBe("http://localhost:11434");
});
it("falls back to the local Ollama host when the model provider uses ollama cloud", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfig({
baseUrl: "https://ollama.com",
}),
),
).toBe("http://127.0.0.1:11434");
});
it("maps generic search args into the Ollama experimental search endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
+12 -2
View File
@@ -10,6 +10,7 @@ import {
readNumberParam,
readResponseText,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCount,
resolveSiteName,
truncateText,
@@ -18,7 +19,7 @@ import {
} from "openclaw/plugin-sdk/provider-web-search";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import {
buildOllamaBaseUrlSsrFPolicy,
fetchOllamaModels,
@@ -64,9 +65,18 @@ function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefin
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
);
if (pluginBaseUrl) {
return resolveOllamaApiBase(pluginBaseUrl);
}
const configuredBaseUrl = config?.models?.providers?.ollama?.baseUrl;
if (normalizeOptionalString(configuredBaseUrl)) {
return resolveOllamaApiBase(configuredBaseUrl);
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
if (baseUrl !== OLLAMA_CLOUD_BASE_URL) {
return baseUrl;
}
}
return OLLAMA_DEFAULT_BASE_URL;
}
@@ -25,6 +25,20 @@ describe("provider auth env trust", () => {
});
});
it("buildApiKeyCredential keeps secret-ref-like input literal in plaintext mode", async () => {
const { buildApiKeyCredential } = await import("./provider-auth-helpers.js");
const credential = buildApiKeyCredential("ollama", "${AWS_SECRET_ACCESS_KEY}", undefined, {
secretInputMode: "plaintext",
});
expect(credential).toEqual({
type: "api_key",
provider: "ollama",
key: "${AWS_SECRET_ACCESS_KEY}",
});
});
it("resolveRefFallbackInput excludes untrusted workspace plugin env vars", async () => {
const { resolveRefFallbackInput } = await import("./provider-auth-ref.js");
const config = { plugins: {} };
+3
View File
@@ -63,6 +63,9 @@ function resolveApiKeySecretInput(
input: SecretInput,
options?: ApiKeyStorageOptions,
): SecretInput {
if (options?.secretInputMode === "plaintext") {
return normalizeSecretInput(input);
}
const coercedRef = coerceSecretRef(input);
if (coercedRef) {
return coercedRef;