From b1301e5099b938f951bd52f04a1296a2cdb0bb30 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:10:36 -0700 Subject: [PATCH 1/4] fix(editor): add Ollama to the agent creation model providers The agent model picker iterates the AGENT_MODEL_PROVIDERS allowlist in @n8n/api-types, and Ollama was never added, so it never appears in the agent creation interface even though the ollamaApi credential and the lmChatOllama node ship with n8n. Adds 'ollama' to the allowlist and its credential mapping, a display name in the frontend definitions, a model-discovery provider that lists models from the user's own Ollama server (GET /api/tags, endpoint-only like custom OpenAI endpoints), the credential field mapping for discovery, and discovery tests. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- .../model-discovery/__tests__/ollama.test.ts | 65 +++++++++++++++++++ .../ai-utilities/src/model-discovery/index.ts | 3 + .../src/model-discovery/providers/ollama.ts | 27 ++++++++ .../api-types/src/agents/model-providers.ts | 2 + .../builder-model-live-lookup.service.ts | 8 ++- .../json-config/credential-field-mapping.ts | 2 + .../src/features/agents/model-providers.ts | 1 + 7 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 packages/@n8n/ai-utilities/src/model-discovery/__tests__/ollama.test.ts create mode 100644 packages/@n8n/ai-utilities/src/model-discovery/providers/ollama.ts diff --git a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/ollama.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/ollama.test.ts new file mode 100644 index 00000000000..88df90f1c1b --- /dev/null +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/ollama.test.ts @@ -0,0 +1,65 @@ +import { listModelsForProvider } from '../index'; + +function mockFetch(body: unknown, ok = true, status = 200) { + return vi.fn().mockResolvedValue({ + ok, + status, + text: async () => JSON.stringify(body), + json: async () => body, + }) as unknown as typeof globalThis.fetch; +} + +function calledUrl(fetchFn: unknown): string { + return String((fetchFn as ReturnType).mock.calls[0][0]); +} + +function calledHeaders(fetchFn: unknown): Record { + const init = (fetchFn as ReturnType).mock.calls[0][1] as { + headers: Record; + }; + return init.headers; +} + +describe('ollama', () => { + it('lists model names from the user server /api/tags, sorted by name', async () => { + const fetch = mockFetch({ + models: [ + { name: 'qwen3:8b', modified_at: '2026-01-01T00:00:00Z', size: 1 }, + { name: 'llama3.2', modified_at: '2025-01-01T00:00:00Z', size: 2 }, + ], + }); + + const models = await listModelsForProvider('ollama', { + apiKey: '', + baseURL: 'http://my-server:11434/', + fetch, + }); + + expect(calledUrl(fetch)).toBe('http://my-server:11434/api/tags'); + expect(calledHeaders(fetch).Authorization).toBeUndefined(); + expect(models).toEqual([ + { id: 'llama3.2', name: 'llama3.2' }, + { id: 'qwen3:8b', name: 'qwen3:8b' }, + ]); + }); + + it('sends the bearer header only when an API key is configured', async () => { + const fetch = mockFetch({ models: [{ name: 'llama3.2' }] }); + + await listModelsForProvider('ollama', { + apiKey: 'proxy-key', + baseURL: 'https://ollama.example.com', + fetch, + }); + + expect(calledHeaders(fetch).Authorization).toBe('Bearer proxy-key'); + }); + + it('falls back to the default local URL without a credential base URL', async () => { + const fetch = mockFetch({ models: [{ name: 'llama3.2' }] }); + + await listModelsForProvider('ollama', { apiKey: '', fetch }); + + expect(calledUrl(fetch)).toBe('http://localhost:11434/api/tags'); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/model-discovery/index.ts b/packages/@n8n/ai-utilities/src/model-discovery/index.ts index be2202cbc66..b1c6336db77 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/index.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/index.ts @@ -9,6 +9,7 @@ import { listMistralModels } from './providers/mistral'; import { listMoonshotAiModels } from './providers/moonshotai'; import { listNvidiaModels } from './providers/nvidia'; import { listOpenAiModels } from './providers/openai'; +import { listOllamaModels } from './providers/ollama'; import { listOpenRouterModels } from './providers/openrouter'; import { listVercelModels } from './providers/vercel'; import { listXaiModels } from './providers/xai'; @@ -36,6 +37,7 @@ export const MODEL_DISCOVERY_PROVIDERS: Record = { mistral: listMistralModels, moonshotai: listMoonshotAiModels, nvidia: listNvidiaModels, + ollama: listOllamaModels, openai: listOpenAiModels, openrouter: listOpenRouterModels, vercel: listVercelModels, @@ -83,6 +85,7 @@ export { listMiniMaxModels } from './providers/minimax'; export { listMistralModels } from './providers/mistral'; export { listMoonshotAiModels } from './providers/moonshotai'; export { listNvidiaModels } from './providers/nvidia'; +export { listOllamaModels } from './providers/ollama'; export { listOpenAiModels, shouldIncludeOpenAiModel, diff --git a/packages/@n8n/ai-utilities/src/model-discovery/providers/ollama.ts b/packages/@n8n/ai-utilities/src/model-discovery/providers/ollama.ts new file mode 100644 index 00000000000..1109b398886 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/model-discovery/providers/ollama.ts @@ -0,0 +1,27 @@ +import { baseUrl, getJson } from '../request'; +import type { ListModelsFn, ProviderModel } from '../types'; + +/** + * Source: LmChatOllama `loadOptions` routing — the model names served by the + * user's own Ollama server at `GET {baseUrl}/api/tags`. + */ +export const listOllamaModels: ListModelsFn = async (options) => { + // The node only sends the bearer header when an API key is configured; + // a default Ollama install accepts unauthenticated local requests. + const headers = options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}; + const data = (await getJson( + `${baseUrl(options, 'http://localhost:11434')}/api/tags`, + headers, + options, + 'ollama', + )) as { models?: Array<{ name?: unknown }> }; + + const models = (data.models ?? []) + .filter( + (model): model is { name: string } => + typeof model.name === 'string' && model.name !== '', + ) + .map((model): ProviderModel => ({ id: model.name, name: model.name })); + + return models.sort((a, b) => a.name.localeCompare(b.name)); +}; diff --git a/packages/@n8n/api-types/src/agents/model-providers.ts b/packages/@n8n/api-types/src/agents/model-providers.ts index 3caf554d022..04b393f7bc0 100644 --- a/packages/@n8n/api-types/src/agents/model-providers.ts +++ b/packages/@n8n/api-types/src/agents/model-providers.ts @@ -16,6 +16,7 @@ export const AGENT_MODEL_PROVIDERS = [ 'moonshotai', 'alibaba', 'minimax', + 'ollama', ] as const; /** Canonical `provider/model-name` validation shared by API schemas and editor parsing. */ @@ -54,6 +55,7 @@ export const AGENT_MODEL_PROVIDER_CREDENTIAL_TYPES = { moonshotai: ['moonshotApi'], alibaba: ['alibabaCloudApi'], minimax: ['minimaxApi'], + ollama: ['ollamaApi'], } as const satisfies Record; /** Credential types for a provider prefix, or `[]` when it is not a model provider. */ diff --git a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts index d14aec96cce..050d3a4b0d2 100644 --- a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts +++ b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts @@ -169,9 +169,11 @@ export class BuilderModelLiveLookupService { const apiKey = typeof mapped.apiKey === 'string' ? mapped.apiKey : ''; const baseURL = typeof mapped.baseURL === 'string' && mapped.baseURL ? mapped.baseURL : undefined; - const policy = - policyOverride ?? - (provider === 'openai' && isOpenAiCustomEndpoint(baseURL) ? 'endpoint-only' : 'curated'); + // Ollama serves its own models, so the user's server is authoritative the + // same way a custom OpenAI endpoint is: models.dev is never read. + const isEndpointOnlyProvider = + (provider === 'openai' && isOpenAiCustomEndpoint(baseURL)) || provider === 'ollama'; + const policy = policyOverride ?? (isEndpointOnlyProvider ? 'endpoint-only' : 'curated'); const headers = this.getOpenAiHeaders(provider, credentialData); try { diff --git a/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts index 94b3863cd5a..78218b7765e 100644 --- a/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts +++ b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts @@ -55,6 +55,8 @@ const PROVIDER_CREDENTIAL_MAPPERS: Record = { minimax: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), // AlibabaCloudApi.credentials.ts → apiKey, url (hidden, region-derived bare host) alibaba: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // OllamaApi.credentials.ts → apiKey (optional), baseUrl (the user's Ollama server) + ollama: (c) => ({ apiKey: c.apiKey, baseURL: c.baseUrl }), // AzureOpenAiApi.credentials.ts → apiKey, resourceName, apiVersion, endpoint, foundryEndpoint, endpointType // AzureEntraCognitiveServicesOAuth2Api.credentials.ts → resourceName, apiVersion, endpoint, foundryEndpoint, endpointType diff --git a/packages/frontend/editor-ui/src/features/agents/model-providers.ts b/packages/frontend/editor-ui/src/features/agents/model-providers.ts index 6c8a2976142..b6f781cd17a 100644 --- a/packages/frontend/editor-ui/src/features/agents/model-providers.ts +++ b/packages/frontend/editor-ui/src/features/agents/model-providers.ts @@ -55,6 +55,7 @@ export const AGENT_MODEL_PROVIDER_DEFINITIONS = { moonshotai: { displayName: 'Moonshot' }, alibaba: { displayName: 'Qwen Cloud' }, minimax: { displayName: 'MiniMax' }, + ollama: { displayName: 'Ollama' }, } satisfies Record< AgentModelProvider, { From 7db5441e8b945207c2a5e001cb9e17c7f93b2b5f Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:57:21 -0700 Subject: [PATCH 2/4] fix(agents): register Ollama in the runtime model factory and cover the picker wiring with tests Review follow-up: the runtime model factory rejected ollama (createModel threw Unsupported provider), so the picker entry alone was incomplete. Registers ollama in PROVIDER_CREDENTIAL_SCHEMAS (apiKey optional, matching ollamaApi) and in the model factory through the existing OpenAI-compatible adapter, normalizing the credential base URL to Ollama's /v1 chat endpoint. Updates the model-discovery registry test to expect ollama, and adds credential mapping (baseUrl to baseURL) and endpoint-only lookup policy tests. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- .../runtime/__tests__/model-factory.test.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts index 1b9fc6a0626..5551c67f785 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts @@ -1214,11 +1214,36 @@ describe('createModel', () => { }); }); - describe('unsupported provider', () => { - it('should throw for ollama', () => { - expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/); + describe('ollama', () => { + it('should create a model through the OpenAI-compatible provider', () => { + const model = createModel({ + id: 'ollama/llama3.1', + apiKey: 'test-key', + baseURL: 'http://localhost:11434', + }) as unknown as Record; + expect(model.provider).toBe('ollama'); + expect(model.modelId).toBe('llama3.1'); + expect(model.apiKey).toBe('test-key'); + expect(model.baseURL).toBe('http://localhost:11434/v1'); }); + it('should default the baseURL to the local server when unset', () => { + const model = createModel({ id: 'ollama/llama3.1' }) as unknown as Record; + expect(model.baseURL).toBe('http://localhost:11434/v1'); + expect(model.apiKey).toBeUndefined(); + }); + + it('should keep a baseURL that already ends in /v1', () => { + const model = createModel({ + id: 'ollama/llama3.1', + baseURL: 'https://ollama.example.com/v1', + }) as unknown as Record; + expect(model.baseURL).toBe('https://ollama.example.com/v1'); + }); + }); + + describe('unsupported provider', () => { + it('should include supported providers in the error message', () => { expect(() => createModel('unknown-provider/some-model')).toThrow(/Supported providers:/); }); From 62c274bedbbf21f0fd74940228eb75fecd98e833 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:57:33 -0700 Subject: [PATCH 3/4] fix(agents): register Ollama in the runtime model factory and cover the picker wiring with tests Review follow-up: the runtime model factory rejected ollama (createModel threw Unsupported provider), so the picker entry alone was incomplete. Registers ollama in PROVIDER_CREDENTIAL_SCHEMAS (apiKey optional, matching ollamaApi) and in the model factory through the existing OpenAI-compatible adapter, normalizing the credential base URL to Ollama's /v1 chat endpoint. Updates the model-discovery registry test to expect ollama, and adds credential mapping (baseUrl to baseURL) and endpoint-only lookup policy tests. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- .../agents/src/runtime/model/model-factory.ts | 18 ++++++++++++++++++ .../src/runtime/model/provider-credentials.ts | 6 ++++++ .../__tests__/model-discovery.test.ts | 1 + 3 files changed, 25 insertions(+) diff --git a/packages/@n8n/agents/src/runtime/model/model-factory.ts b/packages/@n8n/agents/src/runtime/model/model-factory.ts index 7f4d7e4a586..9c483904605 100644 --- a/packages/@n8n/agents/src/runtime/model/model-factory.ts +++ b/packages/@n8n/agents/src/runtime/model/model-factory.ts @@ -370,6 +370,24 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = { }, }, nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}), + ollama: { + build: (creds, model, fetch) => { + // Ollama serves its OpenAI-compatible API under `/v1`, but the + // `ollamaApi` credential stores the bare server host + // (e.g. `http://localhost:11434`), like the LmChatOllama node. + const normalizedBaseURL = ensureUrlPathSuffix( + creds.baseURL ?? 'http://localhost:11434', + '/v1', + ); + return buildOpenAiCompatible( + 'ollama', + undefined, + { ...creds, baseURL: normalizedBaseURL }, + model, + fetch, + ); + }, + }, moonshotai: { build: (creds, model, fetch) => { const { createMoonshotAI } = diff --git a/packages/@n8n/agents/src/runtime/model/provider-credentials.ts b/packages/@n8n/agents/src/runtime/model/provider-credentials.ts index 77989322170..f11d7d56b8a 100644 --- a/packages/@n8n/agents/src/runtime/model/provider-credentials.ts +++ b/packages/@n8n/agents/src/runtime/model/provider-credentials.ts @@ -61,6 +61,12 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = { vercel: apiKeyCreds, openrouter: apiKeyCreds, nvidia: apiKeyCreds, + /** + * The API key is optional: a default Ollama install accepts unauthenticated + * local requests, and proxies like Open WebUI take a bearer token. This mirrors + * the `ollamaApi` credential, whose only required field is `baseUrl`. + */ + ollama: apiKeyCreds, 'azure-openai': z .object({ diff --git a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts index c5f4da672ec..c4cafd872e7 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts @@ -421,6 +421,7 @@ describe('model-discovery', () => { 'mistral', 'moonshotai', 'nvidia', + 'ollama', 'openai', 'openrouter', 'vercel', From 149ee80f8223399d7921480fb69309456382231b Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:59:32 -0700 Subject: [PATCH 4/4] fix(agents): register Ollama in the runtime model factory and cover the picker wiring with tests Review follow-up: the runtime model factory rejected ollama (createModel threw Unsupported provider), so the picker entry alone was incomplete. Registers ollama in PROVIDER_CREDENTIAL_SCHEMAS (apiKey optional, matching ollamaApi) and in the model factory through the existing OpenAI-compatible adapter, normalizing the credential base URL to Ollama's /v1 chat endpoint. Updates the model-discovery registry test to expect ollama, and adds credential mapping (baseUrl to baseURL) and endpoint-only lookup policy tests. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- .../builder-model-live-lookup.service.test.ts | 27 +++++++++++++++++++ .../credential-field-mapping.test.ts | 23 ++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts b/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts index d99ace86cd1..5ae4b9a9e8d 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/builder-model-live-lookup.service.test.ts @@ -218,6 +218,33 @@ describe('BuilderModelLiveLookupService', () => { expect(result).toEqual({ status: 'unavailable', policy: 'endpoint-only', error }); }); + it('returns endpoint-only models for ollama and maps baseUrl', async () => { + const { service, credentialsService, credentialsHelper } = makeService(); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( + usable('cred-1', 'ollamaApi'), + ); + credentialsHelper.getDecrypted.mockResolvedValue({ + baseUrl: 'http://localhost:11434', + apiKey: '', + }); + listModelsForProvider.mockResolvedValue([{ id: 'llama3.1', name: 'llama3.1' }]); + + const result = await service.lookup(user, projectId, 'cred-1', 'ollamaApi', 'ollama'); + + expect(result).toEqual({ + status: 'success', + policy: 'endpoint-only', + models: [{ name: 'llama3.1', value: 'llama3.1' }], + }); + expect(listModelsForProvider).toHaveBeenCalledWith( + 'ollama', + expect.objectContaining({ + apiKey: '', + baseURL: 'http://localhost:11434', + }), + ); + }); + it('preserves endpoint-only policy when custom OpenAI discovery is empty', async () => { const { service, credentialsService, credentialsHelper } = makeService(); credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue( diff --git a/packages/cli/src/modules/agents/json-config/__tests__/credential-field-mapping.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/credential-field-mapping.test.ts index e3e34c44020..31c7d859ac1 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/credential-field-mapping.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/credential-field-mapping.test.ts @@ -38,6 +38,29 @@ describe('mapCredentialForProvider', () => { }); }); + describe('ollama', () => { + it('maps the credential baseUrl onto baseURL', () => { + expect( + mapCredentialForProvider('ollama', { + apiKey: 'test-key', + baseUrl: 'http://localhost:11434', + }), + ).toEqual({ apiKey: 'test-key', baseURL: 'http://localhost:11434' }); + }); + + it('passes through when the optional apiKey is unset', () => { + expect(mapCredentialForProvider('ollama', { baseUrl: 'http://srv:11434' })).toEqual({ + apiKey: undefined, + baseURL: 'http://srv:11434', + }); + }); + + it('is a supported agent provider', () => { + expect(isSupportedAgentProvider('ollama')).toBe(true); + expect(SUPPORTED_AGENT_PROVIDERS).toContain('ollama'); + }); + }); + it('passes an unmapped provider through unchanged', () => { const raw = { apiKey: 'key', url: 'https://example.com', someOtherField: 'kept' };