diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 43057ab8f68..5e4b1581296 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -139,6 +139,125 @@ describe("ollama plugin", () => { expect(buildOllamaProviderMock).not.toHaveBeenCalled(); }); + it("keeps empty default-ish provider stubs quiet", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + + const result = await provider.discovery.run({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }, + }, + }, + env: { NODE_ENV: "development" }, + resolveProviderApiKey: () => ({ apiKey: "" }), + } as never); + + expect(result).toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", { + quiet: true, + }); + }); + + it("treats non-default baseUrl as explicit discovery config", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://remote-ollama:11434", + api: "ollama", + models: [], + }); + + const result = await provider.discovery.run({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://remote-ollama:11434", + api: "ollama", + models: [], + }, + }, + }, + }, + env: { NODE_ENV: "development" }, + resolveProviderApiKey: () => ({ apiKey: "" }), + } as never); + + expect(result).toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://remote-ollama:11434", { + quiet: false, + }); + }); + + it("keeps stored ollama-local marker auth on the quiet ambient path", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + + const result = await provider.discovery.run({ + config: {}, + env: { NODE_ENV: "development" }, + resolveProviderApiKey: () => ({ apiKey: "ollama-local" }), + } as never); + + expect(result).toMatchObject({ + provider: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + apiKey: "ollama-local", + models: [], + }, + }); + expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { + quiet: true, + }); + }); + + it("does not mint synthetic auth for empty default-ish provider stubs", () => { + const provider = registerProvider(); + + const auth = provider.resolveSyntheticAuth?.({ + providerConfig: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }); + + expect(auth).toBeUndefined(); + }); + + it("mints synthetic auth for non-default explicit ollama config", () => { + const provider = registerProvider(); + + const auth = provider.resolveSyntheticAuth?.({ + providerConfig: { + baseUrl: "http://remote-ollama:11434", + api: "ollama", + models: [], + }, + }); + + expect(auth).toEqual({ + apiKey: "ollama-local", + source: "models.providers.ollama (synthetic local key)", + mode: "api-key", + }); + }); + it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => { const provider = registerProvider(); let payloadSeen: Record | undefined; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 02c0e10949b..f7cf7c1a624 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -6,7 +6,10 @@ import { type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; +import { + buildProviderReplayFamilyHooks, + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; import { readStringValue } from "openclaw/plugin-sdk/text-runtime"; import { buildOllamaProvider, @@ -40,6 +43,8 @@ type OllamaPluginConfig = { }; }; +type OllamaProviderLikeConfig = ModelProviderConfig; + function resolveOllamaDiscoveryApiKey(params: { env: NodeJS.ProcessEnv; explicitApiKey?: string; @@ -55,6 +60,43 @@ function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean { return Boolean(env.VITEST) || env.NODE_ENV === "test"; } +function hasMeaningfulExplicitOllamaConfig( + providerConfig: OllamaProviderLikeConfig | undefined, +): boolean { + if (!providerConfig) { + return false; + } + if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) { + return true; + } + if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) { + return resolveOllamaApiBase(providerConfig.baseUrl) !== OLLAMA_DEFAULT_BASE_URL; + } + if (readStringValue(providerConfig.apiKey)) { + return true; + } + if (providerConfig.auth) { + return true; + } + if (typeof providerConfig.authHeader === "boolean") { + return true; + } + if ( + providerConfig.headers && + typeof providerConfig.headers === "object" && + Object.keys(providerConfig.headers).length > 0 + ) { + return true; + } + if (providerConfig.request) { + return true; + } + if (typeof providerConfig.injectNumCtxForOpenAICompat === "boolean") { + return true; + } + return false; +} + export default definePluginEntry({ id: "ollama", name: "Ollama Provider", @@ -113,12 +155,17 @@ export default definePluginEntry({ run: async (ctx: ProviderDiscoveryContext) => { const explicit = ctx.config.models?.providers?.ollama; const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0; + const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit); const discoveryEnabled = pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled; if (!hasExplicitModels && discoveryEnabled === false) { return null; } const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const hasRealOllamaKey = + typeof ollamaKey === "string" && + ollamaKey.trim().length > 0 && + ollamaKey.trim() !== DEFAULT_API_KEY; const explicitApiKey = readStringValue(explicit?.apiKey); if (hasExplicitModels && explicit) { return { @@ -137,12 +184,16 @@ export default definePluginEntry({ }, }; } - if (!ollamaKey && !explicit && shouldSkipAmbientOllamaDiscovery(ctx.env)) { + if ( + !hasRealOllamaKey && + !hasMeaningfulExplicitConfig && + shouldSkipAmbientOllamaDiscovery(ctx.env) + ) { return null; } const provider = await buildOllamaProvider(explicit?.baseUrl, { - quiet: !ollamaKey && !explicit, + quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig, }); if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) { return null; @@ -210,11 +261,7 @@ export default definePluginEntry({ /\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) || /\btruncating input\b.*\btoo long\b/i.test(errorMessage), resolveSyntheticAuth: ({ providerConfig }) => { - const hasApiConfig = - Boolean(providerConfig?.api?.trim()) || - Boolean(providerConfig?.baseUrl?.trim()) || - (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0); - if (!hasApiConfig) { + if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) { return undefined; } return { diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index f12d7f48cb6..39c4e5a5a98 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -41,11 +41,18 @@ vi.mock("../plugins/provider-runtime.js", async () => { return undefined; } const providerConfig = params.context.providerConfig; - const hasApiConfig = - Boolean(providerConfig?.api?.trim()) || - Boolean(providerConfig?.baseUrl?.trim()) || - (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0); - if (!hasApiConfig) { + const hasMeaningfulOllamaConfig = + params.provider !== "ollama" + ? Boolean(providerConfig?.api?.trim()) || + Boolean(providerConfig?.baseUrl?.trim()) || + (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) + : (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) || + Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") || + Boolean( + providerConfig?.baseUrl?.trim() && + providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434", + ); + if (!hasMeaningfulOllamaConfig) { return undefined; } return { @@ -410,6 +417,28 @@ describe("getApiKeyForModel", () => { }); }); + it("does not mint synthetic local auth for default-ish ollama stubs", async () => { + await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => { + await expect( + resolveApiKeyForProvider({ + provider: "ollama", + store: { version: 1, profiles: {} }, + cfg: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }, + }, + }, + }), + ).rejects.toThrow(/No API key found for provider "ollama"/); + }); + }); + it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => { await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => { // pragma: allowlist secret diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index af74612b4f7..9dc79eb9a2d 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -95,11 +95,14 @@ vi.mock("../plugins/provider-runtime.js", async () => { return undefined; } const providerConfig = params.context.providerConfig; - const hasApiConfig = - Boolean(providerConfig?.api?.trim()) || - Boolean(providerConfig?.baseUrl?.trim()) || - (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0); - if (!hasApiConfig) { + const hasMeaningfulOllamaConfig = + (Array.isArray(providerConfig?.models) && providerConfig.models.length > 0) || + Boolean(providerConfig?.api?.trim() && providerConfig.api.trim() !== "ollama") || + Boolean( + providerConfig?.baseUrl?.trim() && + providerConfig.baseUrl.trim().replace(/\/+$/, "") !== "http://127.0.0.1:11434", + ); + if (!hasMeaningfulOllamaConfig) { return undefined; } return { diff --git a/src/agents/models-config.providers.ollama-autodiscovery.test.ts b/src/agents/models-config.providers.ollama-autodiscovery.test.ts index 524aef7b0bc..8e2b132c8af 100644 --- a/src/agents/models-config.providers.ollama-autodiscovery.test.ts +++ b/src/agents/models-config.providers.ollama-autodiscovery.test.ts @@ -138,7 +138,7 @@ describe("Ollama auto-discovery", () => { await runOllamaCatalog({ explicitProviders: { ollama: { - baseUrl: "http://127.0.0.1:11434/v1", + baseUrl: "http://gpu-node-server:11434/v1", api: "openai-completions", models: [], }, diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 18cdd4bfb72..3a55fed8c05 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -416,6 +416,42 @@ export function describeOllamaProviderDiscoveryContract() { ).resolves.toBeNull(); expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); }); + + it("keeps empty default-ish provider stubs on the quiet ambient path", async () => { + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + + await expect( + runCatalog(state, { + provider: state.ollamaProvider!, + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", { + quiet: true, + }); + }); }); }