From ae3f36f738f8d0e6f2549db68ff5324d2dd5627e Mon Sep 17 00:00:00 2001 From: andy <1327523532@qq.com> Date: Tue, 21 Apr 2026 10:18:59 +0800 Subject: [PATCH] Add Ollama provider integration --- README.md | 21 ++++ package-lock.json | 105 +++++++++++------- package.json | 3 +- src/main/agent/runtime.ts | 13 ++- src/main/ipc/models.ts | 95 +++++++++++++++- src/main/storage.ts | 35 +++++- .../src/components/chat/ModelSwitcher.tsx | 30 +++-- 7 files changed, 237 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 0ab63be4..2a4e2076 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,26 @@ npm run dev Or configure them in-app via the settings panel. +### Using Ollama + +openwork can use local Ollama models without an API key. + +```bash +# Install and start Ollama +ollama serve + +# Pull at least one local model +ollama pull llama3.1:8b + +# Optional: point openwork at a non-default Ollama server +export OLLAMA_BASE_URL=http://127.0.0.1:11434 + +# OLLAMA_HOST also works, including host:port values like 127.0.0.1:11434 +export OLLAMA_HOST=127.0.0.1:11434 +``` + +If you need the setting to persist for the app, add `OLLAMA_BASE_URL=...` or `OLLAMA_HOST=...` to `~/.openwork/.env`. + ## Supported Models | Provider | Models | @@ -45,6 +65,7 @@ Or configure them in-app via the settings panel. | Anthropic | Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.1, Claude Sonnet 4 | | OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o | | Google | Gemini 3 Pro Preview, Gemini 3 Flash Preview, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite | +| Ollama | Any local Ollama chat model returned by your Ollama server, such as llama3.1:8b or qwen3:14b | ## Contributing diff --git a/package-lock.json b/package-lock.json index f830e6a0..85afb26f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@langchain/langgraph": "^1.0.15", "@langchain/langgraph-checkpoint": "^1.0.0", "@langchain/langgraph-sdk": "^1.5.3", + "@langchain/ollama": "^1.1.0", "@langchain/openai": "^1.2.3", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -1441,6 +1442,35 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@langchain/ollama": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.1.0.tgz", + "integrity": "sha512-kEP0zvj+xtJ84QcK1ALKkz71ynpWMgzXbASzabMhkRAMSN/wTVm28HUNPRDHxbS9+BZnq1zcZeuzOf29XCal5g==", + "license": "MIT", + "dependencies": { + "ollama": "^0.6.3", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, + "node_modules/@langchain/ollama/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@langchain/openai": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.3.tgz", @@ -3532,6 +3562,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/yauzl": { @@ -3914,6 +3945,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4391,6 +4423,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4481,6 +4514,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4493,6 +4527,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/comma-separated-tokens": { @@ -4563,15 +4598,6 @@ "node": ">=10" } }, - "node_modules/console-table-printer": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", - "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", - "license": "MIT", - "dependencies": { - "simple-wcswidth": "^1.1.2" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6058,6 +6084,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6986,23 +7013,20 @@ } }, "node_modules/langsmith": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.6.tgz", - "integrity": "sha512-9aYop1fEwA8RgFuvv8XPeV9ieeSnKnVRn3bNemkFQCyINLAxfNHC547bVMW8i8MuS1F1pgKwopqhLNf80qS1bQ==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.19.tgz", + "integrity": "sha512-5tFoETuFMvGkbPGsINNlIE4Ab86CsPhdPOQZCGwNt/NX0h5NDKQLKOWS/G2XcRUBOQl4mCNbrayUvUTWaIRsCg==", "license": "MIT", "dependencies": { - "@types/uuid": "^10.0.0", - "chalk": "^4.1.2", - "console-table-printer": "^2.12.1", - "p-queue": "^6.6.2", - "semver": "^7.6.3", - "uuid": "^10.0.0" + "p-queue": "6.6.2", + "uuid": "10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", - "openai": "*" + "openai": "*", + "ws": ">=7" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -7016,21 +7040,12 @@ }, "openai": { "optional": true + }, + "ws": { + "optional": true } } }, - "node_modules/langsmith/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/langsmith/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -8509,6 +8524,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ollama": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", + "integrity": "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9754,12 +9778,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-wcswidth": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", - "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -9968,6 +9986,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -10531,9 +10550,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -11120,6 +11139,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", diff --git a/package.json b/package.json index afb0cf54..5f49534f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@langchain/langgraph": "^1.0.15", "@langchain/langgraph-checkpoint": "^1.0.0", "@langchain/langgraph-sdk": "^1.5.3", + "@langchain/ollama": "^1.1.0", "@langchain/openai": "^1.2.3", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -116,4 +117,4 @@ "picomatch": ">=4.0.4" } } -} \ No newline at end of file +} diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd6..48862cc1 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" import { getDefaultModel } from "../ipc/models" -import { getApiKey, getThreadCheckpointPath } from "../storage" +import { getApiKey, getOllamaBaseUrl, getThreadCheckpointPath } from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" import { ChatGoogleGenerativeAI } from "@langchain/google-genai" +import { ChatOllama } from "@langchain/ollama" import { SqlJsSaver } from "../checkpointer/sqljs-saver" import { LocalSandbox } from "./local-sandbox" @@ -61,7 +62,7 @@ export async function closeCheckpointer(threadId: string): Promise { // Get the appropriate model instance based on configuration function getModelInstance( modelId?: string -): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { +): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | ChatOllama { const model = modelId || getDefaultModel() console.log("[Runtime] Using model:", model) @@ -103,8 +104,12 @@ function getModelInstance( }) } - // Default to model string (let deepagents handle it) - return model + const baseUrl = getOllamaBaseUrl() + console.log("[Runtime] Using Ollama base URL:", baseUrl) + return new ChatOllama({ + model, + baseUrl + }) } export interface CreateAgentRuntimeOptions { diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b3..3f5860f2 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -5,13 +5,21 @@ import * as path from "path" import type { ModelConfig, Provider, + ProviderId, SetApiKeyParams, WorkspaceSetParams, WorkspaceLoadParams, WorkspaceFileParams } from "../types" import { startWatching, stopWatching } from "../services/workspace-watcher" -import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from "../storage" +import { + getOpenworkDir, + getApiKey, + setApiKey, + deleteApiKey, + hasApiKey, + getOllamaBaseUrl +} from "../storage" // Store for non-sensitive settings only (no encryption needed) const store = new Store({ @@ -23,7 +31,8 @@ const store = new Store({ const PROVIDERS: Omit[] = [ { id: "anthropic", name: "Anthropic" }, { id: "openai", name: "OpenAI" }, - { id: "google", name: "Google" } + { id: "google", name: "Google" }, + { id: "ollama", name: "Ollama" } ] // Available models configuration (updated Jan 2026) @@ -204,13 +213,87 @@ const AVAILABLE_MODELS: ModelConfig[] = [ } ] +interface OllamaTagsResponse { + models?: Array<{ + name: string + model?: string + size?: number + details?: { + family?: string + parameter_size?: string + } + }> +} + +function isProviderConfigured(providerId: ProviderId): boolean { + if (providerId === "ollama") { + return true + } + + return hasApiKey(providerId) +} + +function formatBytes(size?: number): string | undefined { + if (!size || Number.isNaN(size)) return undefined + + const units = ["B", "KB", "MB", "GB", "TB"] + let value = size + let unitIndex = 0 + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const digits = value >= 10 || unitIndex === 0 ? 0 : 1 + return `${value.toFixed(digits)} ${units[unitIndex]}` +} + +async function listOllamaModels(): Promise { + const baseUrl = getOllamaBaseUrl().replace(/\/$/, "") + + try { + const response = await fetch(`${baseUrl}/api/tags`, { + signal: AbortSignal.timeout(3000) + }) + + if (!response.ok) { + console.warn(`[Models] Ollama tags request failed: ${response.status}`) + return [] + } + + const data = (await response.json()) as OllamaTagsResponse + + return (data.models ?? []).map((model) => { + const detailParts = [ + model.details?.family, + model.details?.parameter_size, + formatBytes(model.size) + ].filter(Boolean) + + return { + id: model.name, + name: model.name, + provider: "ollama", + model: model.model ?? model.name, + description: detailParts.join(" • ") || `Local Ollama model from ${baseUrl}`, + available: true + } + }) + } catch (error) { + console.warn("[Models] Failed to list Ollama models:", error) + return [] + } +} + export function registerModelHandlers(ipcMain: IpcMain): void { // List available models ipcMain.handle("models:list", async () => { - // Check which models have API keys configured - return AVAILABLE_MODELS.map((model) => ({ + const ollamaModels = await listOllamaModels() + + return [...AVAILABLE_MODELS, ...ollamaModels].map((model) => ({ ...model, - available: hasApiKey(model.provider) + available: isProviderConfigured(model.provider) })) }) @@ -243,7 +326,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void { ipcMain.handle("models:listProviders", async () => { return PROVIDERS.map((provider) => ({ ...provider, - hasApiKey: hasApiKey(provider.id) + hasApiKey: isProviderConfigured(provider.id) })) }) diff --git a/src/main/storage.ts b/src/main/storage.ts index d09686cf..3a2f8de1 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,6 +5,7 @@ import type { ProviderId } from "./types" const OPENWORK_DIR = join(homedir(), ".openwork") const ENV_FILE = join(OPENWORK_DIR, ".env") +const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" // Environment variable names for each provider const ENV_VAR_NAMES: Record = { @@ -52,6 +53,25 @@ export function getEnvFilePath(): string { return ENV_FILE } +export function getEnvValue(name: string): string | undefined { + const env = parseEnvFile() + if (env[name]) return env[name] + return process.env[name] +} + +function normalizeOllamaBaseUrl(value: string | undefined): string | undefined { + if (!value) return undefined + + const trimmed = value.trim() + if (!trimmed) return undefined + + if (/^https?:\/\//i.test(trimmed)) { + return trimmed + } + + return `http://${trimmed}` +} + // Read .env file and parse into object function parseEnvFile(): Record { const envPath = getEnvFilePath() @@ -87,12 +107,7 @@ export function getApiKey(provider: string): string | undefined { const envVarName = ENV_VAR_NAMES[provider] if (!envVarName) return undefined - // Check .env file first - const env = parseEnvFile() - if (env[envVarName]) return env[envVarName] - - // Fall back to process environment - return process.env[envVarName] + return getEnvValue(envVarName) } export function setApiKey(provider: string, apiKey: string): void { @@ -122,3 +137,11 @@ export function deleteApiKey(provider: string): void { export function hasApiKey(provider: string): boolean { return !!getApiKey(provider) } + +export function getOllamaBaseUrl(): string { + return ( + normalizeOllamaBaseUrl(getEnvValue("OLLAMA_BASE_URL")) ?? + normalizeOllamaBaseUrl(getEnvValue("OLLAMA_HOST")) ?? + DEFAULT_OLLAMA_BASE_URL + ) +} diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 45ea665c..814897b5 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react" -import { ChevronDown, Check, AlertCircle, Key } from "lucide-react" +import { ChevronDown, Check, AlertCircle, Key, Bot } from "lucide-react" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Button } from "@/components/ui/button" import { useAppStore } from "@/lib/store" @@ -37,14 +37,15 @@ const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, google: GoogleIcon, - ollama: () => null // No icon for ollama yet + ollama: Bot } // Fallback providers in case the backend hasn't loaded them yet const FALLBACK_PROVIDERS: Provider[] = [ { id: "anthropic", name: "Anthropic", hasApiKey: false }, { id: "openai", name: "OpenAI", hasApiKey: false }, - { id: "google", name: "Google", hasApiKey: false } + { id: "google", name: "Google", hasApiKey: false }, + { id: "ollama", name: "Ollama", hasApiKey: true } ] interface ModelSwitcherProps { @@ -76,11 +77,14 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme (displayProviders.length > 0 ? displayProviders[0].id : null) const selectedModel = models.find((m) => m.id === currentModel) + const SelectedProviderIcon = selectedModel ? PROVIDER_ICONS[selectedModel.provider] : null const filteredModels = effectiveProviderId ? models.filter((m) => m.provider === effectiveProviderId) : [] const selectedProvider = displayProviders.find((p) => p.id === effectiveProviderId) + const providerRequiresApiKey = selectedProvider?.id !== "ollama" + function handleProviderClick(provider: Provider): void { setSelectedProviderId(provider.id) } @@ -115,7 +119,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme > {selectedModel ? ( <> - {PROVIDER_ICONS[selectedModel.provider]?.({ className: "size-3.5" })} + {SelectedProviderIcon && } {selectedModel.id} ) : ( @@ -151,7 +155,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme > {Icon && } {provider.name} - {!provider.hasApiKey && ( + {provider.id !== "ollama" && !provider.hasApiKey && ( )} @@ -166,7 +170,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme Model - {selectedProvider && !selectedProvider.hasApiKey ? ( + {selectedProvider && providerRequiresApiKey && !selectedProvider.hasApiKey ? ( // No API key configured
@@ -200,12 +204,16 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme ))} {filteredModels.length === 0 && ( -

No models available

+

+ {selectedProvider?.id === "ollama" + ? "No Ollama models found. Run ollama serve, then pull a model like llama3.1:8b." + : "No models available"} +

)}
{/* Configure API key link for providers that have a key */} - {selectedProvider?.hasApiKey && ( + {selectedProvider?.hasApiKey && selectedProvider.id !== "ollama" && ( )} + + {selectedProvider?.id === "ollama" && ( +
+ Uses your local Ollama server at OLLAMA_BASE_URL or http://127.0.0.1:11434. +
+ )} )}