diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6d540f5a5..4a350b81dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ Docs: https://docs.openclaw.ai - CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee. - Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino. +### Fixes + +- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. + ## 2026.2.9 ### Added diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index e1730464ca2..3b9624a8eb6 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -2,7 +2,27 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProviders, resolveOllamaApiBase } from "./models-config.providers.js"; + +describe("resolveOllamaApiBase", () => { + it("returns default localhost base when no configured URL is provided", () => { + expect(resolveOllamaApiBase()).toBe("http://127.0.0.1:11434"); + }); + + it("strips /v1 suffix from OpenAI-compatible URLs", () => { + expect(resolveOllamaApiBase("http://ollama-host:11434/v1")).toBe("http://ollama-host:11434"); + expect(resolveOllamaApiBase("http://ollama-host:11434/V1")).toBe("http://ollama-host:11434"); + }); + + it("keeps URLs without /v1 unchanged", () => { + expect(resolveOllamaApiBase("http://ollama-host:11434")).toBe("http://ollama-host:11434"); + }); + + it("handles trailing slash before canonicalizing", () => { + expect(resolveOllamaApiBase("http://ollama-host:11434/v1/")).toBe("http://ollama-host:11434"); + expect(resolveOllamaApiBase("http://ollama-host:11434/")).toBe("http://ollama-host:11434"); + }); +}); describe("Ollama provider", () => { it("should not include ollama when no API key is configured", async () => { @@ -33,6 +53,28 @@ describe("Ollama provider", () => { } }); + it("should preserve explicit ollama baseUrl on implicit provider injection", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + process.env.OLLAMA_API_KEY = "test-key"; + + try { + const providers = await resolveImplicitProviders({ + agentDir, + explicitProviders: { + ollama: { + baseUrl: "http://192.168.20.14:11434/v1", + api: "openai-completions", + models: [], + }, + }, + }); + + expect(providers?.ollama?.baseUrl).toBe("http://192.168.20.14:11434/v1"); + } finally { + delete process.env.OLLAMA_API_KEY; + } + }); + it("should have correct model structure with streaming disabled (unit test)", () => { // This test directly verifies the model configuration structure // since discoverOllamaModels() returns empty array in test mode diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index f5723c53b0c..a4725c5a230 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -111,13 +111,31 @@ interface OllamaTagsResponse { models: OllamaModel[]; } -async function discoverOllamaModels(): Promise { +/** + * 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 discoverOllamaModels(baseUrl?: string): Promise { // Skip Ollama discovery in test environments if (process.env.VITEST || process.env.NODE_ENV === "test") { return []; } try { - const response = await fetch(`${OLLAMA_API_BASE_URL}/api/tags`, { + const apiBase = resolveOllamaApiBase(baseUrl); + const response = await fetch(`${apiBase}/api/tags`, { signal: AbortSignal.timeout(5000), }); if (!response.ok) { @@ -410,10 +428,10 @@ async function buildVeniceProvider(): Promise { }; } -async function buildOllamaProvider(): Promise { - const models = await discoverOllamaModels(); +async function buildOllamaProvider(configuredBaseUrl?: string): Promise { + const models = await discoverOllamaModels(configuredBaseUrl); return { - baseUrl: OLLAMA_BASE_URL, + baseUrl: configuredBaseUrl ?? OLLAMA_BASE_URL, api: "openai-completions", models, }; @@ -456,6 +474,7 @@ export function buildQianfanProvider(): ProviderConfig { export async function resolveImplicitProviders(params: { agentDir: string; + explicitProviders?: Record | null; }): Promise { const providers: Record = {}; const authStore = ensureAuthProfileStore(params.agentDir, { @@ -541,12 +560,15 @@ export async function resolveImplicitProviders(params: { break; } - // Ollama provider - only add if explicitly configured + // Ollama provider - only add if explicitly configured. + // Use the user's configured baseUrl (from explicit providers) for model + // discovery so that remote / non-default Ollama instances are reachable. const ollamaKey = resolveEnvApiKeyVarName("ollama") ?? resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); if (ollamaKey) { - providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; + const ollamaBaseUrl = params.explicitProviders?.ollama?.baseUrl; + providers.ollama = { ...(await buildOllamaProvider(ollamaBaseUrl)), apiKey: ollamaKey }; } const togetherKey = diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 6664905ff4b..b44c0d60b60 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -86,7 +86,7 @@ export async function ensureOpenClawModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); const explicitProviders = cfg.models?.providers ?? {}; - const implicitProviders = await resolveImplicitProviders({ agentDir }); + const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders,