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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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: {} };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user