Onboard: add Ollama auth flow and improve model defaults

Add Ollama as a auth provider in onboarding with Cloud + Local mode
selection, browser-based sign-in via /api/me, smart model suggestions
per mode, and graceful fallback when the default model is unavailable.

- Extract shared ollama-models.ts
- Auto-pull missing models during onboarding
- Non-interactive mode support for CI/automation

Closes #8239
Closes #3494

Co-Authored-By: Jeffrey Morgan <jmorganca@gmail.com>
This commit is contained in:
Bruce MacDonald
2026-03-03 17:21:37 -08:00
committed by Peter Steinberger
parent 62d5df28dc
commit d6108a6f72
16 changed files with 1176 additions and 61 deletions

View File

@@ -1,5 +1,6 @@
import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { ModelDefinitionConfig } from "../config/types.models.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { isReasoningModelHeuristic } from "./ollama-models.js";
const log = createSubsystemLogger("huggingface-models"); const log = createSubsystemLogger("huggingface-models");
@@ -125,7 +126,7 @@ export function buildHuggingfaceModelDefinition(
*/ */
function inferredMetaFromModelId(id: string): { name: string; reasoning: boolean } { function inferredMetaFromModelId(id: string): { name: string; reasoning: boolean } {
const base = id.split("/").pop() ?? id; const base = id.split("/").pop() ?? id;
const reasoning = /r1|reasoning|thinking|reason/i.test(id) || /-\d+[tb]?-thinking/i.test(base); const reasoning = isReasoningModelHeuristic(id);
const name = base.replace(/-/g, " ").replace(/\b(\w)/g, (c) => c.toUpperCase()); const name = base.replace(/-/g, " ").replace(/\b(\w)/g, (c) => c.toUpperCase());
return { name, reasoning }; return { name, reasoning };
} }

View File

@@ -9,27 +9,26 @@ import {
buildHuggingfaceModelDefinition, buildHuggingfaceModelDefinition,
} from "./huggingface-models.js"; } from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js"; import { discoverKilocodeModels } from "./kilocode-models.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; import {
OLLAMA_DEFAULT_CONTEXT_WINDOW,
OLLAMA_DEFAULT_COST,
OLLAMA_DEFAULT_MAX_TOKENS,
isReasoningModelHeuristic,
resolveOllamaApiBase,
type OllamaTagsResponse,
} from "./ollama-models.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
export { resolveOllamaApiBase } from "./ollama-models.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>; type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string]; type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
const log = createSubsystemLogger("agents/model-providers"); const log = createSubsystemLogger("agents/model-providers");
const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL;
const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL;
const OLLAMA_SHOW_CONCURRENCY = 8; const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200; const OLLAMA_SHOW_MAX_MODELS = 200;
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
const VLLM_DEFAULT_CONTEXT_WINDOW = 128000; const VLLM_DEFAULT_CONTEXT_WINDOW = 128000;
@@ -41,44 +40,12 @@ const VLLM_DEFAULT_COST = {
cacheWrite: 0, cacheWrite: 0,
}; };
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
details?: {
family?: string;
parameter_size?: string;
};
}
interface OllamaTagsResponse {
models: OllamaModel[];
}
type VllmModelsResponse = { type VllmModelsResponse = {
data?: Array<{ data?: Array<{
id?: string; id?: string;
}>; }>;
}; };
/**
* Derive the Ollama native API base URL from a configured base URL.
*
* Users typically configure `baseUrl` with a `/v1` suffix (e.g.
* `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
* The native Ollama API lives at the root (e.g. `/api/tags`), so we
* strip the `/v1` suffix when present.
*/
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_API_BASE_URL;
}
// Strip trailing slash, then strip /v1 suffix if present
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
async function queryOllamaContextWindow( async function queryOllamaContextWindow(
apiBase: string, apiBase: string,
modelName: string, modelName: string,
@@ -147,12 +114,10 @@ async function discoverOllamaModels(
batch.map(async (model) => { batch.map(async (model) => {
const modelId = model.name; const modelId = model.name;
const contextWindow = await queryOllamaContextWindow(apiBase, modelId); const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
const isReasoning =
modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning");
return { return {
id: modelId, id: modelId,
name: modelId, name: modelId,
reasoning: isReasoning, reasoning: isReasoningModelHeuristic(modelId),
input: ["text"], input: ["text"],
cost: OLLAMA_DEFAULT_COST, cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW, contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
@@ -204,13 +169,10 @@ async function discoverVllmModels(
.filter((model) => Boolean(model.id)) .filter((model) => Boolean(model.id))
.map((model) => { .map((model) => {
const modelId = model.id; const modelId = model.id;
const lower = modelId.toLowerCase();
const isReasoning =
lower.includes("r1") || lower.includes("reasoning") || lower.includes("think");
return { return {
id: modelId, id: modelId,
name: modelId, name: modelId,
reasoning: isReasoning, reasoning: isReasoningModelHeuristic(modelId),
input: ["text"], input: ["text"],
cost: VLLM_DEFAULT_COST, cost: VLLM_DEFAULT_COST,
contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW, contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW,

View File

@@ -0,0 +1,85 @@
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
export const OLLAMA_DEFAULT_BASE_URL = OLLAMA_NATIVE_BASE_URL;
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
export const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export type OllamaTagModel = {
name: string;
modified_at?: string;
size?: number;
digest?: string;
remote_host?: string;
details?: {
family?: string;
parameter_size?: string;
};
};
export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
/**
* Derive the Ollama native API base URL from a configured base URL.
*
* Users typically configure `baseUrl` with a `/v1` suffix (e.g.
* `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
* The native Ollama API lives at the root (e.g. `/api/tags`), so we
* strip the `/v1` suffix when present.
*/
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_DEFAULT_BASE_URL;
}
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
/** Heuristic: treat models with "r1", "reasoning", or "think" in the name as reasoning models. */
export function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);
}
/** Build a ModelDefinitionConfig for an Ollama model with default values. */
export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
): ModelDefinitionConfig {
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
};
}
/** Fetch the model list from a running Ollama instance. */
export async function fetchOllamaModels(
baseUrl: string,
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const response = await fetch(`${apiBase}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = (await response.json()) as OllamaTagsResponse;
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} catch {
return { reachable: false, models: [] };
}
}

View File

@@ -42,6 +42,7 @@ describe("buildAuthChoiceOptions", () => {
"byteplus-api-key", "byteplus-api-key",
"vllm", "vllm",
"opencode-go", "opencode-go",
"ollama",
]) { ]) {
expect(options.some((opt) => opt.value === value)).toBe(true); expect(options.some((opt) => opt.value === value)).toBe(true);
} }
@@ -93,4 +94,15 @@ describe("buildAuthChoiceOptions", () => {
expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-zen")).toBe(true); expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-zen")).toBe(true);
expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true); expect(openCodeGroup?.options.some((opt) => opt.value === "opencode-go")).toBe(true);
}); });
it("shows Ollama in grouped provider selection", () => {
const { groups } = buildAuthChoiceGroups({
store: EMPTY_STORE,
includeSkip: false,
});
const ollamaGroup = groups.find((group) => group.value === "ollama");
expect(ollamaGroup).toBeDefined();
expect(ollamaGroup?.options.some((opt) => opt.value === "ollama")).toBe(true);
});
}); });

View File

@@ -47,6 +47,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "Local/self-hosted OpenAI-compatible", hint: "Local/self-hosted OpenAI-compatible",
choices: ["vllm"], choices: ["vllm"],
}, },
{
value: "ollama",
label: "Ollama",
hint: "Cloud and local open models",
choices: ["ollama"],
},
{ {
value: "minimax", value: "minimax",
label: "MiniMax", label: "MiniMax",
@@ -238,6 +244,11 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
label: "vLLM (custom URL + model)", label: "vLLM (custom URL + model)",
hint: "Local/self-hosted OpenAI-compatible server", hint: "Local/self-hosted OpenAI-compatible server",
}, },
{
value: "ollama",
label: "Ollama",
hint: "Cloud and local open models",
},
...buildProviderAuthChoiceOptions(), ...buildProviderAuthChoiceOptions(),
{ {
value: "moonshot-api-key-cn", value: "moonshot-api-key-cn",

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
import { applyAuthChoiceOllama } from "./auth-choice.apply.ollama.js";
type PromptAndConfigureOllama = typeof import("./ollama-setup.js").promptAndConfigureOllama;
const promptAndConfigureOllama = vi.hoisted(() =>
vi.fn<PromptAndConfigureOllama>(async ({ cfg }) => ({
config: cfg,
defaultModelId: "qwen3.5:35b",
})),
);
const ensureOllamaModelPulled = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./ollama-setup.js", () => ({
promptAndConfigureOllama,
ensureOllamaModelPulled,
}));
function buildParams(overrides: Partial<ApplyAuthChoiceParams> = {}): ApplyAuthChoiceParams {
return {
authChoice: "ollama",
config: {},
prompter: {} as ApplyAuthChoiceParams["prompter"],
runtime: {} as ApplyAuthChoiceParams["runtime"],
setDefaultModel: false,
...overrides,
};
}
describe("applyAuthChoiceOllama", () => {
it("returns agentModelOverride when setDefaultModel is false", async () => {
const config = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } };
promptAndConfigureOllama.mockResolvedValueOnce({
config,
defaultModelId: "qwen2.5-coder:7b",
});
const result = await applyAuthChoiceOllama(
buildParams({
config,
setDefaultModel: false,
}),
);
expect(result).toEqual({
config,
agentModelOverride: "ollama/qwen2.5-coder:7b",
});
// Pull is deferred — the wizard model picker handles it.
expect(ensureOllamaModelPulled).not.toHaveBeenCalled();
});
it("sets global default model and preserves fallbacks when setDefaultModel is true", async () => {
const config = {
agents: {
defaults: {
model: {
primary: "openai/gpt-4o-mini",
fallbacks: ["anthropic/claude-sonnet-4-5"],
},
},
},
};
promptAndConfigureOllama.mockResolvedValueOnce({
config,
defaultModelId: "qwen2.5-coder:7b",
});
const result = await applyAuthChoiceOllama(
buildParams({
config,
setDefaultModel: true,
}),
);
expect(result?.agentModelOverride).toBeUndefined();
expect(result?.config.agents?.defaults?.model).toEqual({
primary: "ollama/qwen2.5-coder:7b",
fallbacks: ["anthropic/claude-sonnet-4-5"],
});
expect(ensureOllamaModelPulled).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,31 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { ensureOllamaModelPulled, promptAndConfigureOllama } from "./ollama-setup.js";
import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js";
export async function applyAuthChoiceOllama(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice !== "ollama") {
return null;
}
const { config, defaultModelId } = await promptAndConfigureOllama({
cfg: params.config,
prompter: params.prompter,
agentDir: params.agentDir,
});
// Set an Ollama default so the model picker pre-selects an Ollama model.
const defaultModel = `ollama/${defaultModelId}`;
const configWithDefault = applyAgentDefaultModelPrimary(config, defaultModel);
if (!params.setDefaultModel) {
// Defer pulling: the interactive wizard will show a model picker next,
// so avoid downloading a model the user may not choose.
return { config, agentModelOverride: defaultModel };
}
await ensureOllamaModelPulled({ config: configWithDefault, prompter: params.prompter });
return { config: configWithDefault };
}

View File

@@ -9,6 +9,7 @@ import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js"; import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
import { applyAuthChoiceOllama } from "./auth-choice.apply.ollama.js";
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js"; import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js";
import { applyAuthChoiceVllm } from "./auth-choice.apply.vllm.js"; import { applyAuthChoiceVllm } from "./auth-choice.apply.vllm.js";
@@ -38,6 +39,7 @@ export async function applyAuthChoice(
const handlers: Array<(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>> = [ const handlers: Array<(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>> = [
applyAuthChoiceAnthropic, applyAuthChoiceAnthropic,
applyAuthChoiceVllm, applyAuthChoiceVllm,
applyAuthChoiceOllama,
applyAuthChoiceOpenAI, applyAuthChoiceOpenAI,
applyAuthChoiceOAuth, applyAuthChoiceOAuth,
applyAuthChoiceApiProviders, applyAuthChoiceApiProviders,

View File

@@ -7,6 +7,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
token: "anthropic", token: "anthropic",
apiKey: "anthropic", apiKey: "anthropic",
vllm: "vllm", vllm: "vllm",
ollama: "ollama",
"openai-codex": "openai-codex", "openai-codex": "openai-codex",
"codex-cli": "openai-codex", "codex-cli": "openai-codex",
chutes: "chutes", chutes: "chutes",

View File

@@ -22,6 +22,7 @@ import {
} from "./test-wizard-helpers.js"; } from "./test-wizard-helpers.js";
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
type PromptAndConfigureOllama = typeof import("./ollama-setup.js").promptAndConfigureOllama;
vi.mock("../providers/github-copilot-auth.js", () => ({ vi.mock("../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}), githubCopilotLoginCommand: vi.fn(async () => {}),
@@ -44,6 +45,16 @@ vi.mock("./zai-endpoint-detect.js", () => ({
detectZaiEndpoint, detectZaiEndpoint,
})); }));
const promptAndConfigureOllama = vi.hoisted(() =>
vi.fn<PromptAndConfigureOllama>(async ({ cfg }) => ({
config: cfg,
defaultModelId: "qwen3.5:35b",
})),
);
vi.mock("./ollama-setup.js", () => ({
promptAndConfigureOllama,
}));
type StoredAuthProfile = { type StoredAuthProfile = {
key?: string; key?: string;
keyRef?: { source: string; provider: string; id: string }; keyRef?: { source: string; provider: string; id: string };
@@ -131,6 +142,11 @@ describe("applyAuthChoice", () => {
detectZaiEndpoint.mockResolvedValue(null); detectZaiEndpoint.mockResolvedValue(null);
loginOpenAICodexOAuth.mockReset(); loginOpenAICodexOAuth.mockReset();
loginOpenAICodexOAuth.mockResolvedValue(null); loginOpenAICodexOAuth.mockResolvedValue(null);
promptAndConfigureOllama.mockReset();
promptAndConfigureOllama.mockImplementation(async ({ cfg }) => ({
config: cfg,
defaultModelId: "qwen3.5:35b",
}));
await lifecycle.cleanup(); await lifecycle.cleanup();
activeStateDir = null; activeStateDir = null;
}); });
@@ -1350,6 +1366,7 @@ describe("resolvePreferredProviderForAuthChoice", () => {
{ authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" },
{ authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" },
{ authChoice: "mistral-api-key" as const, expectedProvider: "mistral" }, { authChoice: "mistral-api-key" as const, expectedProvider: "mistral" },
{ authChoice: "ollama" as const, expectedProvider: "ollama" },
{ authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, { authChoice: "unknown" as AuthChoice, expectedProvider: undefined },
] as const; ] as const;
for (const scenario of scenarios) { for (const scenario of scenarios) {

View File

@@ -0,0 +1,391 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./ollama-setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../agents/auth-profiles.js", () => ({
upsertAuthProfileWithLock,
}));
const openUrlMock = vi.hoisted(() => vi.fn(async () => false));
vi.mock("./onboard-helpers.js", async (importOriginal) => {
const original = await importOriginal<typeof import("./onboard-helpers.js")>();
return { ...original, openUrl: openUrlMock };
});
const isRemoteEnvironmentMock = vi.hoisted(() => vi.fn(() => false));
vi.mock("./oauth-env.js", () => ({
isRemoteEnvironment: isRemoteEnvironmentMock,
}));
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
upsertAuthProfileWithLock.mockClear();
openUrlMock.mockClear();
isRemoteEnvironmentMock.mockReset().mockReturnValue(false);
});
it("returns suggested default model for local mode", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }));
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(result.defaultModelId).toBe("glm-4.7-flash");
});
it("returns suggested default model for remote mode", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(result.defaultModelId).toBe("kimi-k2.5:cloud");
});
it("mode selection affects model ordering (local)", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({ models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }] }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(result.defaultModelId).toBe("glm-4.7-flash");
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("glm-4.7-flash");
expect(modelIds).toContain("llama3:8b");
});
it("cloud+local mode triggers /api/me check and opens sign-in URL", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
confirm: vi.fn().mockResolvedValueOnce(true),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(openUrlMock).toHaveBeenCalledWith("https://ollama.com/signin");
expect(prompter.confirm).toHaveBeenCalled();
});
it("cloud+local mode does not open browser in remote environment", async () => {
isRemoteEnvironmentMock.mockReturnValue(true);
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
confirm: vi.fn().mockResolvedValueOnce(true),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(openUrlMock).not.toHaveBeenCalled();
});
it("local mode does not trigger cloud auth", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }));
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain("/api/tags");
});
it("suggested models appear first in model list (cloud+local)", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("remote"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }, { name: "deepseek-r1:14b" }],
}),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual([
"kimi-k2.5:cloud",
"minimax-m2.5:cloud",
"glm-5:cloud",
"llama3:8b",
"glm-4.7-flash",
"deepseek-r1:14b",
]);
});
describe("ensureOllamaModelPulled", () => {
it("pulls model when not available locally", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
// /api/tags — model not present
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
// /api/pull
.mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "ollama/glm-4.7-flash" } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
},
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][0]).toContain("/api/pull");
});
it("skips pull when model is already available", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "glm-4.7-flash" }] }));
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "ollama/glm-4.7-flash" } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
},
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("skips pull for cloud models", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "ollama/kimi-k2.5:cloud" } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
},
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when model is not an ollama model", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "openai/gpt-4o" } } },
},
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
it("uses discovered model when requested non-interactive download fails", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "qwen2.5-coder:7b" }] }))
.mockResolvedValueOnce(new Response('{"error":"disk full"}\n', { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const result = await configureOllamaNonInteractive({
nextConfig: {
agents: {
defaults: {
model: {
primary: "openai/gpt-4o-mini",
fallbacks: ["anthropic/claude-sonnet-4-5"],
},
},
},
},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "missing-model",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(result.agents?.defaults?.model).toEqual({
primary: "ollama/qwen2.5-coder:7b",
fallbacks: ["anthropic/claude-sonnet-4-5"],
});
});
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [] }))
.mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "ollama/llama3.2:latest",
},
runtime,
});
const pullRequest = fetchMock.mock.calls[1]?.[1];
expect(JSON.parse(String(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/llama3.2:latest" }),
);
});
it("accepts cloud models in non-interactive mode without pulling", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ models: [] }));
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "kimi-k2.5:cloud",
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
"kimi-k2.5:cloud",
);
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/kimi-k2.5:cloud" }),
);
});
it("exits when Ollama is unreachable", async () => {
const fetchMock = vi.fn().mockRejectedValueOnce(new Error("connect ECONNREFUSED"));
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const nextConfig = {};
const result = await configureOllamaNonInteractive({
nextConfig,
opts: {
customBaseUrl: "http://127.0.0.1:11435",
customModelId: "llama3.2:latest",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Ollama could not be reached at http://127.0.0.1:11435."),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(result).toBe(nextConfig);
});
});

View File

@@ -0,0 +1,511 @@
import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
import {
OLLAMA_DEFAULT_BASE_URL,
buildOllamaModelDefinition,
fetchOllamaModels,
resolveOllamaApiBase,
} from "../agents/ollama-models.js";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js";
import { isRemoteEnvironment } from "./oauth-env.js";
import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js";
import { openUrl } from "./onboard-helpers.js";
import type { OnboardMode, OnboardOptions } from "./onboard-types.js";
export { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-models.js";
export const OLLAMA_DEFAULT_MODEL = "glm-4.7-flash";
const OLLAMA_SUGGESTED_MODELS_LOCAL = ["glm-4.7-flash"];
const OLLAMA_SUGGESTED_MODELS_CLOUD = [
"kimi-k2.5:cloud",
"minimax-m2.5:cloud",
"glm-5:cloud",
];
function normalizeOllamaModelName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase().startsWith("ollama/")) {
const withoutPrefix = trimmed.slice("ollama/".length).trim();
return withoutPrefix || undefined;
}
return trimmed;
}
function isOllamaCloudModel(modelName: string | undefined): boolean {
return Boolean(modelName?.trim().toLowerCase().endsWith(":cloud"));
}
function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } {
const trimmed = status.trim();
const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i);
if (partStatusMatch) {
return { text: `${partStatusMatch[1]} part`, hidePercent: false };
}
if (/^verifying\b.*\bdigest\b/i.test(trimmed)) {
return { text: "verifying digest", hidePercent: true };
}
return { text: trimmed, hidePercent: false };
}
type OllamaCloudAuthResult = {
signedIn: boolean;
signinUrl?: string;
};
/** Check if the user is signed in to Ollama cloud via /api/me. */
async function checkOllamaCloudAuth(baseUrl: string): Promise<OllamaCloudAuthResult> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const response = await fetch(`${apiBase}/api/me`, {
method: "POST",
signal: AbortSignal.timeout(5000),
});
if (response.status === 401) {
// 401 body contains { error, signin_url }
const data = (await response.json()) as { signin_url?: string };
return { signedIn: false, signinUrl: data.signin_url };
}
if (!response.ok) {
return { signedIn: false };
}
return { signedIn: true };
} catch {
// /api/me not supported or unreachable — fail closed so cloud mode
// doesn't silently skip auth; the caller handles the fallback.
return { signedIn: false };
}
}
type OllamaPullChunk = {
status?: string;
total?: number;
completed?: number;
error?: string;
};
type OllamaPullFailureKind = "http" | "no-body" | "chunk-error" | "network";
type OllamaPullResult =
| { ok: true }
| {
ok: false;
kind: OllamaPullFailureKind;
message: string;
};
async function pullOllamaModelCore(params: {
baseUrl: string;
modelName: string;
onStatus?: (status: string, percent: number | null) => void;
}): Promise<OllamaPullResult> {
const { onStatus } = params;
const baseUrl = resolveOllamaApiBase(params.baseUrl);
const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim();
try {
const response = await fetch(`${baseUrl}/api/pull`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
});
if (!response.ok) {
return {
ok: false,
kind: "http",
message: `Failed to download ${modelName} (HTTP ${response.status})`,
};
}
if (!response.body) {
return {
ok: false,
kind: "no-body",
message: `Failed to download ${modelName} (no response body)`,
};
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const layers = new Map<string, { total: number; completed: number }>();
const parseLine = (line: string): OllamaPullResult => {
const trimmed = line.trim();
if (!trimmed) {
return { ok: true };
}
try {
const chunk = JSON.parse(trimmed) as OllamaPullChunk;
if (chunk.error) {
return {
ok: false,
kind: "chunk-error",
message: `Download failed: ${chunk.error}`,
};
}
if (!chunk.status) {
return { ok: true };
}
if (chunk.total && chunk.completed !== undefined) {
layers.set(chunk.status, { total: chunk.total, completed: chunk.completed });
let totalSum = 0;
let completedSum = 0;
for (const layer of layers.values()) {
totalSum += layer.total;
completedSum += layer.completed;
}
const percent = totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null;
onStatus?.(chunk.status, percent);
} else {
onStatus?.(chunk.status, null);
}
} catch {
// Ignore malformed lines from streaming output.
}
return { ok: true };
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) {
return parsed;
}
}
}
const trailing = buffer.trim();
if (trailing) {
const parsed = parseLine(trailing);
if (!parsed.ok) {
return parsed;
}
}
return { ok: true };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return {
ok: false,
kind: "network",
message: `Failed to download ${modelName}: ${reason}`,
};
}
}
/** Pull a model from Ollama, streaming progress updates. */
async function pullOllamaModel(
baseUrl: string,
modelName: string,
prompter: WizardPrompter,
): Promise<boolean> {
const spinner = prompter.progress(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({
baseUrl,
modelName,
onStatus: (status, percent) => {
const displayStatus = formatOllamaPullStatus(status);
if (displayStatus.hidePercent) {
spinner.update(`Downloading ${modelName} - ${displayStatus.text}`);
} else {
spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`);
}
},
});
if (!result.ok) {
spinner.stop(result.message);
return false;
}
spinner.stop(`Downloaded ${modelName}`);
return true;
}
async function pullOllamaModelNonInteractive(
baseUrl: string,
modelName: string,
runtime: RuntimeEnv,
): Promise<boolean> {
runtime.log(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({ baseUrl, modelName });
if (!result.ok) {
runtime.error(result.message);
return false;
}
runtime.log(`Downloaded ${modelName}`);
return true;
}
function buildOllamaModelsConfig(modelNames: string[]) {
return modelNames.map((name) => buildOllamaModelDefinition(name));
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
): OpenClawConfig {
return {
...cfg,
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: {
...cfg.models?.providers,
ollama: {
baseUrl,
api: "ollama",
apiKey: "OLLAMA_API_KEY", // pragma: allowlist secret
models: buildOllamaModelsConfig(modelNames),
},
},
},
};
}
async function storeOllamaCredential(agentDir?: string): Promise<void> {
await upsertAuthProfileWithLock({
profileId: "ollama:default",
credential: { type: "api_key", provider: "ollama", key: "ollama-local" },
agentDir,
});
}
/**
* Interactive: prompt for base URL, discover models, configure provider.
* Model selection is handled by the standard model picker downstream.
*/
export async function promptAndConfigureOllama(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
agentDir?: string;
}): Promise<{ config: OpenClawConfig; defaultModelId: string }> {
const { prompter } = params;
// 1. Prompt base URL
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"),
});
const configuredBaseUrl = String(baseUrlRaw ?? "")
.trim()
.replace(/\/+$/, "");
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
// 2. Check reachability
const { reachable, models } = await fetchOllamaModels(baseUrl);
const modelNames = models.map((m) => m.name);
if (!reachable) {
await prompter.note(
[
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run onboarding.",
].join("\n"),
"Ollama",
);
throw new WizardCancelledError("Ollama not reachable");
}
// 3. Mode selection
const mode = (await prompter.select({
message: "Ollama mode",
options: [
{ value: "remote", label: "Cloud + Local", hint: "Ollama cloud models + local models" },
{ value: "local", label: "Local", hint: "Local models only" },
],
})) as OnboardMode;
// 4. Cloud auth — check /api/me upfront for remote (cloud+local) mode
let cloudAuthVerified = false;
if (mode === "remote") {
const authResult = await checkOllamaCloudAuth(baseUrl);
if (!authResult.signedIn) {
if (authResult.signinUrl) {
if (!isRemoteEnvironment()) {
await openUrl(authResult.signinUrl);
}
await prompter.note(
["Sign in to Ollama Cloud:", authResult.signinUrl].join("\n"),
"Ollama Cloud",
);
const confirmed = await prompter.confirm({
message: "Have you signed in?",
});
if (!confirmed) {
throw new WizardCancelledError("Ollama cloud sign-in cancelled");
}
// Re-check after user claims sign-in
const recheck = await checkOllamaCloudAuth(baseUrl);
if (!recheck.signedIn) {
throw new WizardCancelledError("Ollama cloud sign-in required");
}
cloudAuthVerified = true;
} else {
// No signin URL available (older server, unreachable /api/me, or custom gateway).
await prompter.note(
[
"Could not verify Ollama Cloud authentication.",
"Cloud models may not work until you sign in at https://ollama.com.",
].join("\n"),
"Ollama Cloud",
);
const continueAnyway = await prompter.confirm({
message: "Continue without cloud auth?",
});
if (!continueAnyway) {
throw new WizardCancelledError("Ollama cloud auth could not be verified");
}
// Cloud auth unverified — fall back to local defaults so the model
// picker doesn't steer toward cloud models that may fail.
}
} else {
cloudAuthVerified = true;
}
}
// 5. Model ordering — suggested models first.
// Use cloud defaults only when auth was actually verified; otherwise fall
// back to local defaults so the user isn't steered toward cloud models
// that may fail at runtime.
const suggestedModels =
mode === "local" || !cloudAuthVerified
? OLLAMA_SUGGESTED_MODELS_LOCAL
: OLLAMA_SUGGESTED_MODELS_CLOUD;
const orderedModelNames = [
...suggestedModels,
...modelNames.filter((name) => !suggestedModels.includes(name)),
];
await storeOllamaCredential(params.agentDir);
const defaultModelId = suggestedModels[0] ?? OLLAMA_DEFAULT_MODEL;
const config = applyOllamaProviderConfig(params.cfg, baseUrl, orderedModelNames);
return { config, defaultModelId };
}
/** Non-interactive: auto-discover models and configure provider. */
export async function configureOllamaNonInteractive(params: {
nextConfig: OpenClawConfig;
opts: OnboardOptions;
runtime: RuntimeEnv;
}): Promise<OpenClawConfig> {
const { opts, runtime } = params;
const configuredBaseUrl = (opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).replace(
/\/+$/,
"",
);
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
const { reachable, models } = await fetchOllamaModels(baseUrl);
const modelNames = models.map((m) => m.name);
const explicitModel = normalizeOllamaModelName(opts.customModelId);
if (!reachable) {
runtime.error(
[
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
].join("\n"),
);
runtime.exit(1);
return params.nextConfig;
}
await storeOllamaCredential();
// Apply local suggested model ordering.
const suggestedModels = OLLAMA_SUGGESTED_MODELS_LOCAL;
const orderedModelNames = [
...suggestedModels,
...modelNames.filter((name) => !suggestedModels.includes(name)),
];
const requestedDefaultModelId = explicitModel ?? suggestedModels[0];
let pulledRequestedModel = false;
const availableModelNames = new Set(modelNames);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
if (requestedCloudModel) {
availableModelNames.add(requestedDefaultModelId);
}
// Pull if model not in discovered list and Ollama is reachable
if (!requestedCloudModel && !modelNames.includes(requestedDefaultModelId)) {
pulledRequestedModel = await pullOllamaModelNonInteractive(
baseUrl,
requestedDefaultModelId,
runtime,
);
if (pulledRequestedModel) {
availableModelNames.add(requestedDefaultModelId);
}
}
let allModelNames = orderedModelNames;
let defaultModelId = requestedDefaultModelId;
if ((pulledRequestedModel || requestedCloudModel) && !allModelNames.includes(requestedDefaultModelId)) {
allModelNames = [...allModelNames, requestedDefaultModelId];
}
if (!availableModelNames.has(requestedDefaultModelId)) {
if (availableModelNames.size > 0) {
const firstAvailableModel =
allModelNames.find((name) => availableModelNames.has(name)) ??
Array.from(availableModelNames)[0];
defaultModelId = firstAvailableModel;
runtime.log(
`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`,
);
} else {
runtime.error(
[
`No Ollama models are available at ${baseUrl}.`,
"Pull a model first, then re-run onboarding.",
].join("\n"),
);
runtime.exit(1);
return params.nextConfig;
}
}
const config = applyOllamaProviderConfig(params.nextConfig, baseUrl, allModelNames);
const modelRef = `ollama/${defaultModelId}`;
runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, modelRef);
}
/** Pull the configured default Ollama model if it isn't already available locally. */
export async function ensureOllamaModelPulled(params: {
config: OpenClawConfig;
prompter: WizardPrompter;
}): Promise<void> {
const modelCfg = params.config.agents?.defaults?.model;
const modelId = typeof modelCfg === "string" ? modelCfg : modelCfg?.primary;
if (!modelId?.startsWith("ollama/")) {
return;
}
const baseUrl = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL;
const modelName = modelId.slice("ollama/".length);
if (isOllamaCloudModel(modelName)) {
return;
}
const { models } = await fetchOllamaModels(baseUrl);
if (models.some((m) => m.name === modelName)) {
return;
}
const pulled = await pullOllamaModel(baseUrl, modelName, params.prompter);
if (!pulled) {
throw new WizardCancelledError("Failed to download selected Ollama model");
}
}

View File

@@ -10,6 +10,7 @@ import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.j
import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js";
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
import { applyPrimaryModel } from "../../model-picker.js"; import { applyPrimaryModel } from "../../model-picker.js";
import { configureOllamaNonInteractive } from "../../ollama-setup.js";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyCloudflareAiGatewayConfig, applyCloudflareAiGatewayConfig,
@@ -174,6 +175,10 @@ export async function applyNonInteractiveAuthChoice(params: {
return null; return null;
} }
if (authChoice === "ollama") {
return configureOllamaNonInteractive({ nextConfig, opts, runtime });
}
if (authChoice === "apiKey") { if (authChoice === "apiKey") {
const resolved = await resolveApiKey({ const resolved = await resolveApiKey({
provider: "anthropic", provider: "anthropic",

View File

@@ -10,6 +10,7 @@ export type AuthChoice =
| "token" | "token"
| "chutes" | "chutes"
| "vllm" | "vllm"
| "ollama"
| "openai-codex" | "openai-codex"
| "openai-api-key" | "openai-api-key"
| "openrouter-api-key" | "openrouter-api-key"
@@ -59,6 +60,7 @@ export type AuthChoiceGroupId =
| "anthropic" | "anthropic"
| "chutes" | "chutes"
| "vllm" | "vllm"
| "ollama"
| "google" | "google"
| "copilot" | "copilot"
| "openrouter" | "openrouter"

View File

@@ -1,4 +1,5 @@
import { resolveEnvApiKey } from "../agents/model-auth.js"; import { resolveEnvApiKey } from "../agents/model-auth.js";
import { resolveOllamaApiBase } from "../agents/ollama-models.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
@@ -17,7 +18,6 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">; type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0)); const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
@@ -36,14 +36,6 @@ function normalizeOllamaModel(model: string): string {
}); });
} }
function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return DEFAULT_OLLAMA_BASE_URL;
}
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined { function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined {
const remoteApiKey = resolveMemorySecretInputString({ const remoteApiKey = resolveMemorySecretInputString({
value: options.remote?.apiKey, value: options.remote?.apiKey,

View File

@@ -442,13 +442,17 @@ export async function runOnboardingWizard(
config: nextConfig, config: nextConfig,
prompter, prompter,
runtime, runtime,
setDefaultModel: true, setDefaultModel: !(authChoiceFromPrompt && authChoice === "ollama"),
opts: { opts: {
tokenProvider: opts.tokenProvider, tokenProvider: opts.tokenProvider,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
}, },
}); });
nextConfig = authResult.config; nextConfig = authResult.config;
if (authResult.agentModelOverride) {
nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride);
}
} }
if (authChoiceFromPrompt && authChoice !== "custom-api-key") { if (authChoiceFromPrompt && authChoice !== "custom-api-key") {
@@ -468,6 +472,11 @@ export async function runOnboardingWizard(
} }
} }
if (authChoice === "ollama") {
const { ensureOllamaModelPulled } = await import("../commands/ollama-setup.js");
await ensureOllamaModelPulled({ config: nextConfig, prompter });
}
await warnIfModelConfigLooksOff(nextConfig, prompter); await warnIfModelConfigLooksOff(nextConfig, prompter);
const { configureGatewayForOnboarding } = await import("./onboarding.gateway-config.js"); const { configureGatewayForOnboarding } = await import("./onboarding.gateway-config.js");