mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 06:52:44 +00:00
fix: use configured base URL for Ollama model discovery (#14131)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 2292d2de6d
Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -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.
|
- 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.
|
- 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
|
## 2026.2.9
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,27 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("Ollama provider", () => {
|
||||||
it("should not include ollama when no API key is configured", async () => {
|
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)", () => {
|
it("should have correct model structure with streaming disabled (unit test)", () => {
|
||||||
// This test directly verifies the model configuration structure
|
// This test directly verifies the model configuration structure
|
||||||
// since discoverOllamaModels() returns empty array in test mode
|
// since discoverOllamaModels() returns empty array in test mode
|
||||||
|
|||||||
@@ -111,13 +111,31 @@ interface OllamaTagsResponse {
|
|||||||
models: OllamaModel[];
|
models: OllamaModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverOllamaModels(): Promise<ModelDefinitionConfig[]> {
|
/**
|
||||||
|
* 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<ModelDefinitionConfig[]> {
|
||||||
// Skip Ollama discovery in test environments
|
// Skip Ollama discovery in test environments
|
||||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
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),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -410,10 +428,10 @@ async function buildVeniceProvider(): Promise<ProviderConfig> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildOllamaProvider(): Promise<ProviderConfig> {
|
async function buildOllamaProvider(configuredBaseUrl?: string): Promise<ProviderConfig> {
|
||||||
const models = await discoverOllamaModels();
|
const models = await discoverOllamaModels(configuredBaseUrl);
|
||||||
return {
|
return {
|
||||||
baseUrl: OLLAMA_BASE_URL,
|
baseUrl: configuredBaseUrl ?? OLLAMA_BASE_URL,
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
models,
|
models,
|
||||||
};
|
};
|
||||||
@@ -456,6 +474,7 @@ export function buildQianfanProvider(): ProviderConfig {
|
|||||||
|
|
||||||
export async function resolveImplicitProviders(params: {
|
export async function resolveImplicitProviders(params: {
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
|
explicitProviders?: Record<string, ProviderConfig> | null;
|
||||||
}): Promise<ModelsConfig["providers"]> {
|
}): Promise<ModelsConfig["providers"]> {
|
||||||
const providers: Record<string, ProviderConfig> = {};
|
const providers: Record<string, ProviderConfig> = {};
|
||||||
const authStore = ensureAuthProfileStore(params.agentDir, {
|
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||||
@@ -541,12 +560,15 @@ export async function resolveImplicitProviders(params: {
|
|||||||
break;
|
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 =
|
const ollamaKey =
|
||||||
resolveEnvApiKeyVarName("ollama") ??
|
resolveEnvApiKeyVarName("ollama") ??
|
||||||
resolveApiKeyFromProfiles({ provider: "ollama", store: authStore });
|
resolveApiKeyFromProfiles({ provider: "ollama", store: authStore });
|
||||||
if (ollamaKey) {
|
if (ollamaKey) {
|
||||||
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
|
const ollamaBaseUrl = params.explicitProviders?.ollama?.baseUrl;
|
||||||
|
providers.ollama = { ...(await buildOllamaProvider(ollamaBaseUrl)), apiKey: ollamaKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
const togetherKey =
|
const togetherKey =
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function ensureOpenClawModelsJson(
|
|||||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
|
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
|
||||||
|
|
||||||
const explicitProviders = cfg.models?.providers ?? {};
|
const explicitProviders = cfg.models?.providers ?? {};
|
||||||
const implicitProviders = await resolveImplicitProviders({ agentDir });
|
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
|
||||||
const providers: Record<string, ProviderConfig> = mergeProviders({
|
const providers: Record<string, ProviderConfig> = mergeProviders({
|
||||||
implicit: implicitProviders,
|
implicit: implicitProviders,
|
||||||
explicit: explicitProviders,
|
explicit: explicitProviders,
|
||||||
|
|||||||
Reference in New Issue
Block a user