From f0b5d78ff91b3ba354bdbe0f0ccbd35c19e7625e Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Wed, 17 Jun 2026 03:29:46 +0800 Subject: [PATCH] fix(ollama): preserve configured API during discovery (#93729) * fix(ollama): preserve configured API during discovery * fix(ollama): keep compatible discovery base URL * fix(ollama): route compatible APIs through configured transport --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- extensions/ollama/index.test.ts | 119 ++++++++++++++++++- extensions/ollama/index.ts | 25 +++- extensions/ollama/provider-discovery.test.ts | 31 ++++- extensions/ollama/src/discovery-shared.ts | 28 ++++- 4 files changed, 188 insertions(+), 15 deletions(-) diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index be4b6854cf8..8d74efb3aa8 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -467,6 +467,68 @@ describe("ollama plugin", () => { } }); + it("preserves explicit api for configured dynamic Ollama models", async () => { + const provider = registerProvider(); + const previous = process.env.OLLAMA_API_KEY; + process.env.OLLAMA_API_KEY = "ollama-live"; + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "https://ollama.example.com", + api: "ollama", + models: [ + { + id: "qwen3-coder:cloud", + name: "qwen3-coder:cloud", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }); + + try { + const config = { + models: { + providers: { + ollama: { + baseUrl: "https://ollama.example.com/v1", + api: "openai-completions", + models: [], + }, + }, + }, + }; + + await provider.prepareDynamicModel?.({ + config, + provider: "ollama", + modelId: "qwen3-coder:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + const resolved = provider.resolveDynamicModel?.({ + config, + provider: "ollama", + modelId: "qwen3-coder:cloud", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + expect(resolved?.provider).toBe("ollama"); + expect(resolved?.id).toBe("qwen3-coder:cloud"); + expect(resolved?.api).toBe("openai-completions"); + expect(resolved?.baseUrl).toBe("https://ollama.example.com/v1"); + expect(buildOllamaProviderMock).toHaveBeenCalledWith("https://ollama.example.com/v1", { + quiet: true, + }); + } finally { + if (previous === undefined) { + delete process.env.OLLAMA_API_KEY; + } else { + process.env.OLLAMA_API_KEY = previous; + } + } + }); + it("resolves requested Ollama cloud models that are omitted from tags but confirmed by show", async () => { const provider = registerProvider(); const previous = process.env.OLLAMA_API_KEY; @@ -1335,7 +1397,7 @@ describe("ollama plugin", () => { provider.createStreamFn?.({ config: {}, - model: { id: "kimi-k2.5:cloud" }, + model: { api: "ollama", id: "kimi-k2.5:cloud" }, provider: "ollama-cloud", } as never); expect(requireConfiguredStreamParams().providerBaseUrl).toBe("https://ollama.com"); @@ -1434,6 +1496,55 @@ describe("ollama plugin", () => { expect(nativePolicy?.validateAnthropicTurns).toBe(true); }); + it.each([ + { + providerId: "ollama", + register: registerProvider, + nativeBaseUrl: "http://127.0.0.1:11434", + }, + { + providerId: "ollama-cloud", + register: registerOllamaCloudProvider, + nativeBaseUrl: "https://ollama.com", + }, + ])( + "$providerId selects native /api/chat transport only for api=ollama", + ({ providerId, register, nativeBaseUrl }) => { + const provider = register(); + const createStream = (api: "ollama" | "openai-completions", baseUrl: string) => + provider.createStreamFn?.({ + config: { + models: { + providers: { + [providerId]: { + api, + baseUrl, + models: [], + }, + }, + }, + }, + model: { + api, + id: "qwen3:32b", + provider: providerId, + }, + provider: providerId, + } as never); + + const compatibleStream = createStream("openai-completions", `${nativeBaseUrl}/v1`); + + expect(compatibleStream).toBeUndefined(); + expect(createConfiguredOllamaStreamFnMock).not.toHaveBeenCalled(); + + const nativeStream = createStream("ollama", nativeBaseUrl); + + expect(nativeStream).toBeDefined(); + expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledOnce(); + expect(requireConfiguredStreamParams().providerBaseUrl).toBe(nativeBaseUrl); + }, + ); + it("routes createStreamFn to the correct provider baseUrl for ollama2", () => { const provider = registerProvider(); const config = { @@ -1452,7 +1563,7 @@ describe("ollama plugin", () => { }, }, }; - const model = { id: "llama3.2", provider: "ollama2", baseUrl: undefined }; + const model = { id: "llama3.2", provider: "ollama2", api: "ollama", baseUrl: undefined }; provider.createStreamFn?.({ config, model, provider: "ollama2" } as never); @@ -1472,7 +1583,7 @@ describe("ollama plugin", () => { }, }, }; - const model = { id: "llama3.2", provider: "ollama2", baseUrl: undefined }; + const model = { id: "llama3.2", provider: "ollama2", api: "ollama", baseUrl: undefined }; provider.createStreamFn?.({ config, model, provider: "ollama2" } as never); @@ -1497,7 +1608,7 @@ describe("ollama plugin", () => { }, }, }; - const model = { id: "llama3.2", provider: "ollama", baseUrl: undefined }; + const model = { id: "llama3.2", provider: "ollama", api: "ollama", baseUrl: undefined }; provider.createStreamFn?.({ config, model, provider: "ollama" } as never); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 2279c19fa9e..d24ad390752 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -47,6 +47,7 @@ import { OLLAMA_PROVIDER_ID, isLocalOllamaBaseUrl, resolveOllamaDiscoveryResult, + resolveOllamaRuntimeBaseUrl, shouldUseSyntheticOllamaAuth, type OllamaPluginConfig, } from "./src/discovery-shared.js"; @@ -104,7 +105,7 @@ function toDynamicOllamaModel(params: { id: params.model.id, name: params.model.name ?? params.model.id, provider: params.provider, - api: "ollama", + api: params.providerConfig.api ?? "ollama", baseUrl: readProviderBaseUrl(params.providerConfig) ?? "", reasoning: params.model.reasoning ?? false, input: input.length > 0 ? input : ["text"], @@ -475,6 +476,9 @@ export default definePluginEntry({ }), }, createStreamFn: ({ config, model, provider }) => { + if (model.api !== "ollama") { + return undefined; + } return createConfiguredOllamaStreamFn({ model, providerBaseUrl: @@ -597,6 +601,9 @@ export default definePluginEntry({ await ensureOllamaModelPulled({ config, model, prompter }); }, createStreamFn: ({ config, model, provider }) => { + if (model.api !== "ollama") { + return undefined; + } return createConfiguredOllamaStreamFn({ model, providerBaseUrl: readProviderBaseUrl( @@ -658,17 +665,27 @@ export default definePluginEntry({ } const baseUrl = readProviderBaseUrl(providerConfig); const provider = await buildOllamaProvider(baseUrl, { quiet: true }); - const dynamicModels = (provider.models ?? []).map((model) => + const dynamicApi = providerConfig?.api ?? provider.api; + const dynamicProvider = { + ...provider, + baseUrl: resolveOllamaRuntimeBaseUrl({ + api: dynamicApi, + configuredBaseUrl: baseUrl, + discoveredBaseUrl: provider.baseUrl, + }), + api: dynamicApi, + }; + const dynamicModels = (dynamicProvider.models ?? []).map((model) => toDynamicOllamaModel({ provider: ctx.provider, - providerConfig: provider, + providerConfig: dynamicProvider, model, }), ); if (!dynamicModels.some((model) => model.id === ctx.modelId)) { const requestedModel = await resolveRequestedDynamicOllamaModel({ provider: ctx.provider, - providerConfig: provider, + providerConfig: dynamicProvider, modelId: ctx.modelId, }); if (requestedModel) { diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index e3278827410..634ac4e39c9 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -150,7 +150,7 @@ describe("Ollama provider", () => { }); }); - it("should preserve explicit ollama baseUrl on implicit provider injection", async () => { + it("should preserve explicit ollama baseUrl and api on implicit provider injection", async () => { const fetchMock = stubTagsFetch(); await withOllamaApiKey(async () => { @@ -171,8 +171,33 @@ describe("Ollama provider", () => { expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1); - // Native API strips /v1 suffix via resolveOllamaApiBase() + expect(provider?.baseUrl).toBe("http://192.168.20.14:11434/v1"); + expect(provider?.api).toBe("openai-completions"); + }); + }); + + it("should normalize explicit native ollama baseUrl on implicit provider injection", async () => { + const fetchMock = stubTagsFetch(); + + await withOllamaApiKey(async () => { + const provider = await runOllamaCatalog({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://192.168.20.14:11434/v1", + api: "ollama", + models: [], + }, + }, + }, + }, + env: { OLLAMA_API_KEY: "test-key" }, + }); + + expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1); expect(provider?.baseUrl).toBe("http://192.168.20.14:11434"); + expect(provider?.api).toBe("ollama"); }); }); @@ -650,7 +675,7 @@ describe("Ollama provider", () => { }); expect(provider?.apiKey).toBe("config-ollama-key"); - expect(provider?.baseUrl).toBe("http://remote-ollama:11434"); + expect(provider?.baseUrl).toBe("http://remote-ollama:11434/v1"); expect(provider?.api).toBe("openai-completions"); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 82f6f34e4cd..1df5a251043 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -42,6 +42,17 @@ function isOllamaApiKeyMarker(value: string): boolean { return value === "OLLAMA_API_KEY" || value === OLLAMA_DEFAULT_API_KEY; } +export function resolveOllamaRuntimeBaseUrl(params: { + api?: ModelProviderConfig["api"]; + configuredBaseUrl?: string; + discoveredBaseUrl: string; +}): string { + if (params.configuredBaseUrl && params.api && params.api !== "ollama") { + return params.configuredBaseUrl; + } + return params.discoveredBaseUrl; +} + function resolveOllamaDiscoveryApiKey(params: { env: NodeJS.ProcessEnv; baseUrl?: string; @@ -251,10 +262,12 @@ export async function resolveOllamaDiscoveryResult(params: { ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY; const explicitApiKey = readStringValue(explicit?.apiKey); if (hasExplicitModels && explicit) { - const baseUrl = resolveOllamaApiBase(readProviderBaseUrl(explicit) ?? OLLAMA_DEFAULT_BASE_URL); + const configuredBaseUrl = readProviderBaseUrl(explicit) ?? OLLAMA_DEFAULT_BASE_URL; + const discoveredBaseUrl = resolveOllamaApiBase(configuredBaseUrl); + const api = explicit.api ?? "ollama"; const apiKey = resolveOllamaDiscoveryApiKey({ env: params.ctx.env, - baseUrl, + baseUrl: discoveredBaseUrl, explicitApiKey, resolvedApiKey: ollamaKey, resolvedDiscoveryApiKey: ollamaDiscoveryKey, @@ -262,8 +275,8 @@ export async function resolveOllamaDiscoveryResult(params: { return { provider: { ...explicit, - baseUrl, - api: explicit.api ?? "ollama", + baseUrl: resolveOllamaRuntimeBaseUrl({ api, configuredBaseUrl, discoveredBaseUrl }), + api, ...(apiKey ? { apiKey } : {}), }, }; @@ -307,9 +320,16 @@ export async function resolveOllamaDiscoveryResult(params: { resolvedApiKey: ollamaKey, resolvedDiscoveryApiKey: ollamaDiscoveryKey, }); + const api = explicit?.api ?? provider.api; return { provider: { ...provider, + baseUrl: resolveOllamaRuntimeBaseUrl({ + api, + configuredBaseUrl, + discoveredBaseUrl: provider.baseUrl, + }), + api, ...(apiKey ? { apiKey } : {}), }, };