fix(ollama): skip auto-discovery for remote/cloud base URLs (#93956)

* fix(ollama): skip auto-discovery for remote/cloud base URLs

When the Ollama provider base URL points to a remote/cloud instance
(e.g. ollama.com), the plugin should not auto-discover all available
models via /api/tags. Cloud instances are shared tenants where the
provider manages the model catalog; users should only get models they
explicitly configure.

- Add remote-baseUrl guard in resolveOllamaDiscoveryResult
- Local/loopback URLs still auto-discover as before
- Remote URLs with explicit models return only those models
- Remote URLs without explicit models return null (skip discovery)
- Add tests covering remote guard, explicit models, and local fallback

* fix ollama cloud discovery ci

* fix(ollama): narrow discovery guard to hosted Ollama Cloud only

The previous guard blocked auto-discovery for ALL remote base URLs
without explicit models. This was too broad — it also blocked
self-hosted Ollama instances at custom domains (e.g.,
https://ollama.mycompany.com).

Replace the !isLocalOllamaBaseUrl() check with a targeted
isHostedOllamaCloud() check that only matches *.ollama.com
hostnames. Remote self-hosted Ollama endpoints now correctly
auto-discover as before.

Add isHostedOllamaCloud() helper with unit tests and a
regression test confirming remote self-hosted URLs still
auto-discover.

* fix(ollama): ensure models array in explicit-models return path

* fix(ollama): replace deprecated config-types import with local type

The openclaw/plugin-sdk/config-types subpath is deprecated and flagged
by the CI architecture check. Replace it with a local OllamaProviderConfigInput
type alias defined from non-deprecated provider-model-shared exports.

- discovery-shared.ts: define OllamaProviderConfigInput locally
- provider-base-url.ts: define OllamaProviderConfigInput locally
- Both files: remove import from openclaw/plugin-sdk/config-types

* chore(ollama): drop unrelated formatting churn
This commit is contained in:
Jason O'Neal
2026-06-22 14:08:05 -04:00
committed by GitHub
parent 7c8ca26364
commit 92264fbb8f
4 changed files with 253 additions and 11 deletions
+1
View File
@@ -138,6 +138,7 @@ const config = {
entry: rootEntries,
ignoreDependencies: [
"@openclaw/*",
"cross-spawn",
"file-type",
"playwright-core",
"sqlite-vec",
+195 -1
View File
@@ -1,6 +1,11 @@
// Ollama tests cover discovery shared plugin behavior.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { describe, expect, it } from "vitest";
import { isLocalOllamaBaseUrl } from "./discovery-shared.js";
import {
isHostedOllamaCloud,
isLocalOllamaBaseUrl,
resolveOllamaDiscoveryResult,
} from "./discovery-shared.js";
describe("isLocalOllamaBaseUrl", () => {
it.each([
@@ -40,3 +45,192 @@ describe("isLocalOllamaBaseUrl", () => {
expect(isLocalOllamaBaseUrl(baseUrl)).toBe(false);
});
});
describe("isHostedOllamaCloud", () => {
it.each([
"https://ollama.com",
"https://ollama.com:11434",
"https://api.ollama.com",
"https://api.ollama.com/v1",
"https://sub.ollama.com",
])("classifies %s as hosted cloud", (baseUrl) => {
expect(isHostedOllamaCloud(baseUrl)).toBe(true);
});
it.each([
undefined,
"",
"http://localhost:11434",
"http://127.0.0.1:11434",
"https://ollama.mycompany.com",
"https://ollama.example.com",
"http://10.0.0.5:11434",
"not a url",
])("classifies %s as not hosted cloud", (baseUrl) => {
expect(isHostedOllamaCloud(baseUrl)).toBe(false);
});
});
describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => {
const discoveredModel = {
id: "discovered-model",
name: "discovered-model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
compat: { supportsTools: true, supportsUsageInStreaming: true },
params: { num_ctx: 128000 },
} satisfies ModelProviderConfig["models"][number];
const cloudModel = {
id: "minimax-m3:cloud",
name: "minimax-m3:cloud",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
compat: { supportsTools: true, supportsUsageInStreaming: true },
params: { num_ctx: 128000 },
} satisfies ModelProviderConfig["models"][number];
const buildMockProvider = async (
_configuredBaseUrl?: string,
_opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> => ({
baseUrl: "https://ollama.com",
api: "ollama",
models: [discoveredModel],
});
it("returns null for remote base URL without explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
expect(result).toBeNull();
});
it("returns explicit models for remote base URL when models are configured", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
models: [cloudModel],
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
expect(result).not.toBeNull();
expect(result!.provider.models).toHaveLength(1);
expect(result!.provider.models[0].id).toBe("minimax-m3:cloud");
});
it("does not call buildProvider for remote base URL without explicit models", async () => {
let providerCalled = false;
const trackingBuildProvider = async (
_configuredBaseUrl?: string,
_opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> => {
providerCalled = true;
return buildMockProvider();
};
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: trackingBuildProvider,
});
expect(result).toBeNull();
expect(providerCalled).toBe(false);
});
it("still auto-discovers for remote self-hosted base URL when no explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.mycompany.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
// Remote self-hosted base URL should still reach the discovery path
expect(result).not.toBeNull();
});
it("still auto-discovers for local base URL when no explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "http://localhost:11434",
api: "ollama",
},
},
},
},
env: { OLLAMA_API_KEY: "ollama-local" },
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
// Local base URL should still reach the discovery path
expect(result).not.toBeNull();
});
});
+41 -7
View File
@@ -1,6 +1,17 @@
// Ollama plugin module implements discovery shared behavior.
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type {
ModelProviderConfig,
ModelDefinitionConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
/**
* Provider config input type — partial config without required `models`.
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
*/
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
models?: ModelDefinitionConfig[];
};
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
@@ -18,7 +29,7 @@ export type OllamaPluginConfig = {
type OllamaDiscoveryContext = {
config: {
models?: {
providers?: Record<string, ModelProviderConfig | undefined>;
providers?: Record<string, OllamaProviderConfigInput | undefined>;
};
};
env: NodeJS.ProcessEnv;
@@ -149,6 +160,22 @@ export function isLocalOllamaBaseUrl(baseUrl: string | undefined | null): boolea
);
}
const HOSTED_OLLAMA_CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
export function isHostedOllamaCloud(baseUrl: string | undefined | null): boolean {
if (!baseUrl) {
return false;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
const host = parsed.hostname.toLowerCase();
return HOSTED_OLLAMA_CLOUD_HOSTNAMES.has(host) || host.endsWith(".ollama.com");
}
function isLoopbackOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
if (!baseUrl) {
return true;
@@ -167,7 +194,7 @@ function isLoopbackOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
}
function hasExplicitRemoteOllamaApiProvider(
providers: Record<string, ModelProviderConfig | undefined> | undefined,
providers: Record<string, OllamaProviderConfigInput | undefined> | undefined,
): boolean {
if (!providers) {
return false;
@@ -188,7 +215,7 @@ function hasExplicitRemoteOllamaApiProvider(
}
export function shouldUseSyntheticOllamaAuth(
providerConfig: ModelProviderConfig | undefined,
providerConfig: OllamaProviderConfigInput | undefined,
): boolean {
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
return false;
@@ -197,7 +224,7 @@ export function shouldUseSyntheticOllamaAuth(
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: ModelProviderConfig | undefined,
providerConfig: OllamaProviderConfigInput | undefined,
): boolean {
if (!providerConfig) {
return false;
@@ -252,6 +279,14 @@ export async function resolveOllamaDiscoveryResult(params: {
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
// When the base URL points to hosted Ollama Cloud, skip auto-discovery.
// Cloud instances are shared tenants where available models are managed
// by the provider; only use explicitly configured models.
// Remote self-hosted Ollama endpoints still auto-discover as before.
const configuredBaseUrl = readProviderBaseUrl(explicit);
if (!hasExplicitModels && configuredBaseUrl && isHostedOllamaCloud(configuredBaseUrl)) {
return null;
}
const resolvedOllamaAuth = params.ctx.resolveProviderApiKey(OLLAMA_PROVIDER_ID);
const ollamaKey = resolvedOllamaAuth.apiKey;
const ollamaDiscoveryKey = resolvedOllamaAuth.discoveryApiKey;
@@ -262,7 +297,6 @@ export async function resolveOllamaDiscoveryResult(params: {
ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
const configuredBaseUrl = readProviderBaseUrl(explicit) ?? OLLAMA_DEFAULT_BASE_URL;
const discoveredBaseUrl = resolveOllamaApiBase(configuredBaseUrl);
const api = explicit.api ?? "ollama";
const apiKey = resolveOllamaDiscoveryApiKey({
@@ -275,6 +309,7 @@ export async function resolveOllamaDiscoveryResult(params: {
return {
provider: {
...explicit,
models: explicit.models ?? [],
baseUrl: resolveOllamaRuntimeBaseUrl({ api, configuredBaseUrl, discoveredBaseUrl }),
api,
...(apiKey ? { apiKey } : {}),
@@ -295,7 +330,6 @@ export async function resolveOllamaDiscoveryResult(params: {
return null;
}
const configuredBaseUrl = readProviderBaseUrl(explicit);
const quiet = !hasRealOllamaKey && !hasMeaningfulExplicitConfig;
const provider = await getCachedLiveCatalogValue({
keyParts: [
+16 -3
View File
@@ -1,7 +1,20 @@
// Ollama provider module implements model/runtime integration.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type {
ModelProviderConfig,
ModelDefinitionConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
export function readProviderBaseUrl(provider: ModelProviderConfig | undefined): string | undefined {
/**
* Provider config input type — partial config without required `models`.
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
*/
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
models?: ModelDefinitionConfig[];
};
export function readProviderBaseUrl(
provider: OllamaProviderConfigInput | undefined,
): string | undefined {
if (!provider) {
return undefined;
}
@@ -12,7 +25,7 @@ export function readProviderBaseUrl(provider: ModelProviderConfig | undefined):
) {
return provider.baseUrl.trim();
}
const alternate = provider as ModelProviderConfig & { baseURL?: unknown };
const alternate = provider as OllamaProviderConfigInput & { baseURL?: unknown };
if (
Object.hasOwn(alternate, "baseURL") &&
typeof alternate.baseURL === "string" &&