diff --git a/config/knip.config.ts b/config/knip.config.ts index d3c8835fbc7a..97184cde1fe4 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -138,6 +138,7 @@ const config = { entry: rootEntries, ignoreDependencies: [ "@openclaw/*", + "cross-spawn", "file-type", "playwright-core", "sqlite-vec", diff --git a/extensions/ollama/src/discovery-shared.test.ts b/extensions/ollama/src/discovery-shared.test.ts index 151f2d6f5ec3..d2290091cbd4 100644 --- a/extensions/ollama/src/discovery-shared.test.ts +++ b/extensions/ollama/src/discovery-shared.test.ts @@ -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 => ({ + 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 => { + 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(); + }); +}); diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 1df5a2510437..a8df4408f8ca 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -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, "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; + providers?: Record; }; }; 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 | undefined, + providers: Record | 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: [ diff --git a/extensions/ollama/src/provider-base-url.ts b/extensions/ollama/src/provider-base-url.ts index a6b1507b8c1f..b80336ff0cac 100644 --- a/extensions/ollama/src/provider-base-url.ts +++ b/extensions/ollama/src/provider-base-url.ts @@ -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, "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" &&