From 341a91a3199b0607ea0f3799c6f38dba6c6dd1f7 Mon Sep 17 00:00:00 2001 From: Duarte Barbosa Date: Wed, 3 Dec 2025 17:37:51 +1000 Subject: [PATCH 1/4] Require explicit Ollama model selection --- apps/web/utils/actions/settings.validation.ts | 5 +- apps/web/utils/llms/model.test.ts | 56 +++++++++++++++---- apps/web/utils/llms/model.ts | 37 +++++++----- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/apps/web/utils/actions/settings.validation.ts b/apps/web/utils/actions/settings.validation.ts index 013ce49952..de095168b7 100644 --- a/apps/web/utils/actions/settings.validation.ts +++ b/apps/web/utils/actions/settings.validation.ts @@ -38,7 +38,10 @@ export const saveAiSettingsBody = z aiApiKey: z.string().optional(), }) .superRefine((val, ctx) => { - if (!val.aiApiKey && val.aiProvider !== DEFAULT_PROVIDER) { + const requiresApiKey = + val.aiProvider !== DEFAULT_PROVIDER && val.aiProvider !== Provider.OLLAMA; + + if (!val.aiApiKey && requiresApiKey) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "You must provide an API key for this provider", diff --git a/apps/web/utils/llms/model.test.ts b/apps/web/utils/llms/model.test.ts index 65d854ba88..f1f2f24ec5 100644 --- a/apps/web/utils/llms/model.test.ts +++ b/apps/web/utils/llms/model.test.ts @@ -3,6 +3,7 @@ import { getModel } from "./model"; import { Provider } from "./config"; import { env } from "@/env"; import type { UserAIFields } from "./types"; +import { createOllama } from "ollama-ai-provider"; // Mock AI provider imports vi.mock("@ai-sdk/openai", () => ({ @@ -50,6 +51,7 @@ vi.mock("@/env", () => ({ ANTHROPIC_API_KEY: "test-anthropic-key", GROQ_API_KEY: "test-groq-key", OPENROUTER_API_KEY: "test-openrouter-key", + OPENROUTER_BACKUP_MODEL: "google/gemini-2.5-flash", OLLAMA_BASE_URL: "http://localhost:11434", NEXT_PUBLIC_OLLAMA_MODEL: "llama3", BEDROCK_REGION: "us-west-2", @@ -75,6 +77,8 @@ describe("Models", () => { vi.mocked(env).DEFAULT_LLM_MODEL = undefined; vi.mocked(env).BEDROCK_ACCESS_KEY = ""; vi.mocked(env).BEDROCK_SECRET_KEY = ""; + vi.mocked(env).NEXT_PUBLIC_OLLAMA_MODEL = "llama3"; + vi.mocked(env).OLLAMA_BASE_URL = "http://localhost:11434"; }); describe("getModel", () => { @@ -153,18 +157,48 @@ describe("Models", () => { expect(result.model).toBeDefined(); }); - // it("should configure Ollama model correctly", () => { - // const userAi: UserAIFields = { - // aiApiKey: "user-api-key", - // aiProvider: Provider.OLLAMA!, - // aiModel: "llama3", - // }; + it("should configure Ollama model correctly", () => { + const userAi: UserAIFields = { + aiApiKey: null, + aiProvider: Provider.OLLAMA!, + aiModel: "llama3", + }; - // const result = getModel(userAi); - // expect(result.provider).toBe(Provider.OLLAMA); - // expect(result.modelName).toBe("llama3"); - // expect(result.model).toBeDefined(); - // }); + const result = getModel(userAi); + expect(result.provider).toBe(Provider.OLLAMA); + expect(result.modelName).toBe("llama3"); + expect(result.model).toBeDefined(); + expect(createOllama).toHaveBeenCalledWith({ + baseURL: "http://localhost:11434", + }); + expect(result.backupModel).toBeNull(); + }); + + it("should throw when Ollama model is missing", () => { + const userAi: UserAIFields = { + aiApiKey: null, + aiProvider: Provider.OLLAMA!, + aiModel: null, + }; + + expect(() => getModel(userAi)).toThrow("Ollama model must be specified"); + }); + + it("should fall back to default Ollama base URL when env missing", () => { + vi.mocked(env).OLLAMA_BASE_URL = undefined as any; + + const userAi: UserAIFields = { + aiApiKey: null, + aiProvider: Provider.OLLAMA!, + aiModel: "llama3", + }; + + getModel(userAi); + + expect(createOllama).toHaveBeenCalledWith({ + baseURL: "http://localhost:11434", + }); + }); it("should configure Anthropic model correctly without Bedrock credentials", () => { const userAi: UserAIFields = { diff --git a/apps/web/utils/llms/model.ts b/apps/web/utils/llms/model.ts index 2495482462..474d8483df 100644 --- a/apps/web/utils/llms/model.ts +++ b/apps/web/utils/llms/model.ts @@ -6,7 +6,7 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createGroq } from "@ai-sdk/groq"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { createGateway } from "@ai-sdk/gateway"; -// import { createOllama } from "ollama-ai-provider"; +import { createOllama } from "ollama-ai-provider"; import { env } from "@/env"; import { Provider } from "@/utils/llms/config"; import type { UserAIFields } from "@/utils/llms/types"; @@ -128,16 +128,21 @@ function selectModel( }; } case Provider.OLLAMA: { - throw new Error( - "Ollama is not supported. Revert to version v1.7.28 or older to use it.", - ); - // const modelName = aiModel || env.NEXT_PUBLIC_OLLAMA_MODEL; - // if (!modelName) throw new Error("Ollama model is not set"); - // return { - // provider: Provider.OLLAMA!, - // modelName, - // model: createOllama({ baseURL: env.OLLAMA_BASE_URL })(model), - // }; + const modelName = aiModel; + + if (!modelName) { + throw new Error("Ollama model must be specified"); + } + + const baseURL = env.OLLAMA_BASE_URL || "http://localhost:11434"; + const ollama = createOllama({ baseURL }); + + return { + provider: Provider.OLLAMA!, + modelName, + model: ollama(modelName), + backupModel: null, + }; } case Provider.BEDROCK: { @@ -208,7 +213,7 @@ function createOpenRouterProviderOptions( function selectEconomyModel(userAi: UserAIFields): SelectModel { if (env.ECONOMY_LLM_PROVIDER && env.ECONOMY_LLM_MODEL) { const apiKey = getProviderApiKey(env.ECONOMY_LLM_PROVIDER); - if (!apiKey) { + if (!apiKey && providerRequiresApiKey(env.ECONOMY_LLM_PROVIDER)) { logger.warn("Economy LLM provider configured but API key not found", { provider: env.ECONOMY_LLM_PROVIDER, }); @@ -245,7 +250,7 @@ function selectEconomyModel(userAi: UserAIFields): SelectModel { function selectChatModel(userAi: UserAIFields): SelectModel { if (env.CHAT_LLM_PROVIDER && env.CHAT_LLM_MODEL) { const apiKey = getProviderApiKey(env.CHAT_LLM_PROVIDER); - if (!apiKey) { + if (!apiKey && providerRequiresApiKey(env.CHAT_LLM_PROVIDER)) { logger.warn("Chat LLM provider configured but API key not found", { provider: env.CHAT_LLM_PROVIDER, }); @@ -285,7 +290,7 @@ function selectDefaultModel(userAi: UserAIFields): SelectModel { // If user has not api key set, then use default model // If they do they can use the model of their choice - if (aiApiKey) { + if (aiApiKey || userAi.aiProvider === Provider.OLLAMA) { aiProvider = userAi.aiProvider || env.DEFAULT_LLM_PROVIDER; aiModel = userAi.aiModel || null; } else { @@ -337,6 +342,10 @@ function getProviderApiKey(provider: string) { return providerApiKeys[provider]; } +function providerRequiresApiKey(provider: string) { + return provider !== Provider.OLLAMA; +} + function getBackupModel(userApiKey: string | null): LanguageModelV2 | null { // disable backup model if user is using their own api key if (userApiKey) return null; From 2e143b7c2f44750484da3e1d3cc4f212ce4ecc18 Mon Sep 17 00:00:00 2001 From: Duarte Barbosa Date: Thu, 4 Dec 2025 00:15:11 +1000 Subject: [PATCH 2/4] Document Ollama setup for self-hosting --- docs/hosting/self-hosting.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 5d9fa0b105..a5dd58a2b8 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -44,6 +44,20 @@ If doing this manually edit then you'll need to configure: - **LLM Provider**: Uncomment one provider block and add your API key - **Optional**: Microsoft OAuth, external Redis, etc. +#### Using Ollama (local LLM) + +To use a locally hosted Ollama model instead of a cloud LLM provider: + +1. Set `NEXT_PUBLIC_OLLAMA_MODEL` in `apps/web/.env` to the exact model name you have pulled in Ollama (e.g., `llama3` or `qwen2.5`). +2. (Optional) Set `OLLAMA_BASE_URL` if your Ollama server is not on the default `http://localhost:11434`. When running the app in Docker but Ollama is on the host, use `http://host.docker.internal:11434`. +3. Restart the stack so the updated environment variables are loaded: + +```bash +NEXT_PUBLIC_BASE_URL=https://yourdomain.com docker compose --env-file apps/web/.env --profile all up -d +``` + +No API key is required for Ollama. The UI will only show Ollama as a selectable provider when `NEXT_PUBLIC_OLLAMA_MODEL` is set. + For detailed configuration instructions, see the [Environment Variables Reference](./environment-variables.md). **Note**: If you only want to use Microsoft and not Google OAuth then add skipped for the the Google client id and secret. From 993eebbeb1534e3c43a9609c460c6ffdfc37bab3 Mon Sep 17 00:00:00 2001 From: Duarte Barbosa Date: Thu, 4 Dec 2025 01:14:11 +1000 Subject: [PATCH 3/4] Update Ollama AI provider to version 2 and adjust imports accordingly --- apps/web/package.json | 2 +- apps/web/utils/llms/model.test.ts | 2 +- apps/web/utils/llms/model.ts | 2 +- pnpm-lock.yaml | 58 ++++++++++++------------------- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 92950d3efe..f534aaecf8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -132,7 +132,7 @@ "next-themes": "0.4.6", "nodemailer": "7.0.9", "nuqs": "2.7.2", - "ollama-ai-provider": "1.2.0", + "ollama-ai-provider-v2": "^1.5.5", "openai": "6.6.0", "p-queue": "9.0.0", "p-retry": "7.1.0", diff --git a/apps/web/utils/llms/model.test.ts b/apps/web/utils/llms/model.test.ts index f1f2f24ec5..4553ba186f 100644 --- a/apps/web/utils/llms/model.test.ts +++ b/apps/web/utils/llms/model.test.ts @@ -3,7 +3,7 @@ import { getModel } from "./model"; import { Provider } from "./config"; import { env } from "@/env"; import type { UserAIFields } from "./types"; -import { createOllama } from "ollama-ai-provider"; +import { createOllama } from "ollama-ai-provider-v2"; // Mock AI provider imports vi.mock("@ai-sdk/openai", () => ({ diff --git a/apps/web/utils/llms/model.ts b/apps/web/utils/llms/model.ts index 474d8483df..3174304e5b 100644 --- a/apps/web/utils/llms/model.ts +++ b/apps/web/utils/llms/model.ts @@ -6,7 +6,7 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createGroq } from "@ai-sdk/groq"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { createGateway } from "@ai-sdk/gateway"; -import { createOllama } from "ollama-ai-provider"; +import { createOllama } from "ollama-ai-provider-v2"; import { env } from "@/env"; import { Provider } from "@/utils/llms/config"; import type { UserAIFields } from "@/utils/llms/types"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52eebcfd99..f2e7a9173c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,9 +450,9 @@ importers: nuqs: specifier: 2.7.2 version: 2.7.2(next@15.5.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) - ollama-ai-provider: - specifier: 1.2.0 - version: 1.2.0(zod@3.25.46) + ollama-ai-provider-v2: + specifier: ^1.5.5 + version: 1.5.5(zod@3.25.46) openai: specifier: 6.6.0 version: 6.6.0(ws@8.18.3)(zod@3.25.46) @@ -689,7 +689,7 @@ importers: version: 5.7.3 vitest: specifier: 2.1.8 - version: 2.1.8(@types/node@22.15.18)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0) + version: 2.1.8(@types/node@22.15.18)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0) packages/loops: dependencies: @@ -846,14 +846,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@2.2.8': - resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + '@ai-sdk/provider-utils@3.0.12': + resolution: {integrity: sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg==} engines: {node: '>=18'} peerDependencies: - zod: ^3.23.8 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.12': - resolution: {integrity: sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg==} + '@ai-sdk/provider-utils@3.0.18': + resolution: {integrity: sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -7875,6 +7875,7 @@ packages: get-random-values-esm@1.0.2: resolution: {integrity: sha512-HMSDTgj1HPFAuZG0FqxzHbYt5JeEGDUeT9r1RLXhS6RZQS8rLRjokgjZ0Pd28CN0lhXlRwfH6eviZqZEJ2kIoA==} + deprecated: use crypto.getRandomValues() instead get-random-values@1.2.2: resolution: {integrity: sha512-lMyPjQyl0cNNdDf2oR+IQ/fM3itDvpoHy45Ymo2r0L1EjazeSl13SfbKZs7KtZ/3MDCeueiaJiuOEfKqRTsSgA==} @@ -9536,14 +9537,11 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - ollama-ai-provider@1.2.0: - resolution: {integrity: sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==} + ollama-ai-provider-v2@1.5.5: + resolution: {integrity: sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true + zod: ^4.0.16 on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} @@ -9743,9 +9741,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - partial-json@0.1.7: - resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} - pascal-case@2.0.1: resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} @@ -10843,9 +10838,6 @@ packages: scrollmirror@1.2.4: resolution: {integrity: sha512-UkEHHOV6j5cE3IsObQRK6vO4twSuhE4vtLD4UmX+doHgrtg2jRwXkz4O6cz0jcoxK5NGU7rFjyvLcWHzw7eQ5A==} - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} @@ -12566,14 +12558,14 @@ snapshots: '@ai-sdk/provider-utils': 3.0.12(zod@3.25.46) zod: 3.25.46 - '@ai-sdk/provider-utils@2.2.8(zod@3.25.46)': + '@ai-sdk/provider-utils@3.0.12(zod@3.25.46)': dependencies: - '@ai-sdk/provider': 1.1.3 - nanoid: 3.3.11 - secure-json-parse: 2.7.0 + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 zod: 3.25.46 - '@ai-sdk/provider-utils@3.0.12(zod@3.25.46)': + '@ai-sdk/provider-utils@3.0.18(zod@3.25.46)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 @@ -22940,12 +22932,10 @@ snapshots: ohash@2.0.11: {} - ollama-ai-provider@1.2.0(zod@3.25.46): + ollama-ai-provider-v2@1.5.5(zod@3.25.46): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.46) - partial-json: 0.1.7 - optionalDependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.18(zod@3.25.46) zod: 3.25.46 on-exit-leak-free@2.1.2: {} @@ -23188,8 +23178,6 @@ snapshots: parseurl@1.3.3: {} - partial-json@0.1.7: {} - pascal-case@2.0.1: dependencies: camel-case: 3.0.0 @@ -24632,8 +24620,6 @@ snapshots: scrollmirror@1.2.4: {} - secure-json-parse@2.7.0: {} - secure-json-parse@4.0.0: {} selderee@0.11.0: @@ -25996,7 +25982,7 @@ snapshots: typescript: 5.9.3 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - vitest@2.1.8(@types/node@22.15.18)(@vitest/ui@3.2.4)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0): + vitest@2.1.8(@types/node@22.15.18)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0): dependencies: '@vitest/expect': 2.1.8 '@vitest/mocker': 2.1.8(vite@5.4.21(@types/node@22.15.18)(terser@5.44.0)) From ab40bfd812c26d9e7df0edd0948bc61f41926324 Mon Sep 17 00:00:00 2001 From: Duarte Barbosa Date: Thu, 4 Dec 2025 01:28:55 +1000 Subject: [PATCH 4/4] Update Ollama AI provider mock to version 2 --- apps/web/utils/llms/model.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/utils/llms/model.test.ts b/apps/web/utils/llms/model.test.ts index 4553ba186f..c815b40410 100644 --- a/apps/web/utils/llms/model.test.ts +++ b/apps/web/utils/llms/model.test.ts @@ -32,7 +32,7 @@ vi.mock("@openrouter/ai-sdk-provider", () => ({ })), })); -vi.mock("ollama-ai-provider", () => ({ +vi.mock("ollama-ai-provider-v2", () => ({ createOllama: vi.fn(() => (model: string) => ({ model })), }));