From 70d97fd13772f080f0e9a530d5860874215f9783 Mon Sep 17 00:00:00 2001 From: JATMN Date: Sun, 20 Sep 2026 18:10:23 -0700 Subject: [PATCH] feat(web-search): add Ollama backend (#2234) * feat(web-search): add Ollama backend * fix(web-search): harden Ollama routing * fix(web-search): preserve Ollama profile identity * fix(web-search): align Ollama diagnostics * fix(web-search): align Ollama endpoint diagnostics * fix(web-search): honor Ollama endpoint aliases * fix(web-search): reject Ollama key placeholders * fix(web-search): complete Ollama configuration guidance --- .env.example | 10 +- README.md | 6 +- docs/advanced-setup.md | 2 + scripts/system-check.test.ts | 217 ++++++++++++++- scripts/system-check.ts | 151 ++++++++++- src/__tests__/providerCounts.test.ts | 1 + .../WebSearchTool/README_SEARCH_PROVIDERS.md | 25 +- src/tools/WebSearchTool/WebSearchTool.test.ts | 2 + src/tools/WebSearchTool/WebSearchTool.ts | 2 +- .../providers/duckduckgo.test.ts | 18 ++ .../WebSearchTool/providers/duckduckgo.ts | 2 +- .../WebSearchTool/providers/index.test.ts | 83 ++++++ src/tools/WebSearchTool/providers/index.ts | 7 +- .../WebSearchTool/providers/ollama.test.ts | 251 ++++++++++++++++++ src/tools/WebSearchTool/providers/ollama.ts | 230 ++++++++++++++++ src/utils/envFile.test.ts | 18 ++ src/utils/envFile.ts | 2 + src/utils/providerProfile.test.ts | 3 + src/utils/providerProfile.ts | 2 + src/utils/providerProfiles.test.ts | 59 ++++ src/utils/providerProfiles.ts | 13 + 21 files changed, 1088 insertions(+), 16 deletions(-) create mode 100644 src/tools/WebSearchTool/providers/ollama.test.ts create mode 100644 src/tools/WebSearchTool/providers/ollama.ts diff --git a/.env.example b/.env.example index 019303a44..0b8bce8ab 100644 --- a/.env.example +++ b/.env.example @@ -549,12 +549,18 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Self-hosted Firecrawl endpoint (optional — omit to use cloud API) # FIRECRAWL_API_URL=https://your-firecrawl-instance.com +# Ollama Web Search. A signed-in local Ollama route needs no key; the key +# enables https://ollama.com/api/web_search as a hosted fallback. +# OLLAMA_BASE_URL=http://localhost:11434 +# OLLAMA_API_KEY=your-ollama-api-key + # ── Provider selection mode ───────────────────────────────────────── # # WEB_SEARCH_PROVIDER controls fallback behavior: # # "auto" (default) — try all configured providers, fall through on failure # "custom" — custom API only, throw on failure (NOT in auto chain) +# "ollama" — local signed-in Ollama, then hosted Ollama when a key is set # "firecrawl" — firecrawl only # "tavily" — tavily only # "exa" — exa only @@ -567,8 +573,8 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # "ddg" — duckduckgo only # "native" — anthropic native / codex only # -# Auto mode priority: firecrawl → tavily → exa → you → jina → brave → bing → -# mojeek → linkup → ddg +# Auto mode priority: ollama → firecrawl → tavily → exa → you → jina → brave → +# bing → mojeek → linkup → ddg # Note: "custom" is NOT in the auto chain. To use the custom API provider, # you must explicitly set WEB_SEARCH_PROVIDER=custom. # diff --git a/README.md b/README.md index 7c8c67c31..644a8ce1a 100644 --- a/README.md +++ b/README.md @@ -398,12 +398,14 @@ See [Agent Routing and Step Limits](docs/agent-routing.md) for the full guide. ## Web Search and Fetch -By default, `WebSearch` works on non-Anthropic models using DuckDuckGo. This gives GPT-4o, DeepSeek, Gemini, Ollama, and other OpenAI-compatible providers a free web search path out of the box. +By default, `WebSearch` works on non-Anthropic models using configured search adapters and then DuckDuckGo. When the active provider is Ollama, OpenClaude first uses the signed-in local Ollama Web Search endpoint. Set `OLLAMA_API_KEY` to enable the hosted Ollama endpoint as a fallback. -> **Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service. If you want a more reliable supported option, configure Firecrawl. +> **Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service. If you want a more reliable supported option, configure Ollama Web Search or Firecrawl. For Anthropic-native backends and Codex responses, OpenClaude keeps the native provider web search behavior. +To select Ollama search explicitly, use `WEB_SEARCH_PROVIDER=ollama`. A local Ollama route uses its configured `OPENAI_BASE_URL` or `OLLAMA_BASE_URL`; hosted search sends `OLLAMA_API_KEY` only to `https://ollama.com/api/web_search`. + `WebFetch` works, but its basic HTTP plus HTML-to-markdown path can still fail on JavaScript-rendered sites or sites that block plain HTTP requests. Set a [Firecrawl](https://firecrawl.dev) API key if you want Firecrawl-powered search/fetch behavior: diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 94e5b4fad..ca453757a 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -502,6 +502,8 @@ host. Without this variable the behavior is unchanged. | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | | `API_TIMEOUT_MS` | No | Request timeout in milliseconds, shared by the OpenAI-compatible and native Anthropic paths (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, empty, zero, negative, or fractional values fall back to the applicable default, and values above `2147483647` are capped. For generic OpenAI-compatible requests, direct GitHub Copilot Responses, and Copilot chat-to-Responses fallback requests it is a time-to-response-headers deadline that is disarmed once headers arrive, so it does not limit response streaming. For native Anthropic clients it sets the Anthropic SDK request timeout, and for the non-streaming fallback it overrides that path's own default (`120000` on remote sessions, `300000` otherwise) under the same validation and clamping. Export this runtime setting from your shell or launcher; the provider env-file loader ignores runtime/debug settings, so a value configured only there leaves the default in effect. First-party Codex OAuth Responses retain their existing timeout handling. | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | +| `OLLAMA_BASE_URL` | Ollama only | Optional Ollama server base URL used for model discovery and the signed-in local Web Search proxy. The Ollama provider profile normally supplies the equivalent `OPENAI_BASE_URL`. | +| `OLLAMA_API_KEY` | Ollama Web Search only | Enables hosted Ollama search at `https://ollama.com/api/web_search` and acts as a fallback when the configured local Ollama search endpoint is unavailable. | | `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB` | No | Explicit V8 old-space heap cap in megabytes for the Node launcher. Used only when `--max-memory`, `--max-old-space-size-percentage`, and `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` are all unset. Default `8192`. | | `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE` | No | Size the V8 old-space heap as a percentage (greater than 0, up to 100) of constrained/container memory when Node reports it, otherwise total system RAM. Converted to `--max-old-space-size` so Node 22.0.0 can start. Precedence, earlier wins: `--max-memory` → `--max-old-space-size-percentage` → this variable → `OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB` → default `8192`. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index a6b55c086..0ac7163af 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -103,6 +103,8 @@ const ENV_KEYS = [ 'GOOGLE_CSE_ID', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', + 'OLLAMA_API_KEY', + 'OLLAMA_BASE_URL', 'TAVILY_API_KEY', 'EXA_API_KEY', 'YOU_API_KEY', @@ -350,7 +352,7 @@ describe('system-check provider diagnostics', () => { describe('system-check WebSearch diagnostics', () => { const reliableBackendHint = - 'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' + 'OLLAMA_BASE_URL, OLLAMA_API_KEY, FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' function expectWebSearchBackend( ok: boolean, @@ -481,6 +483,74 @@ describe('system-check WebSearch diagnostics', () => { ) }) + test('reports the active Ollama search adapter in auto mode', () => { + useOpenAICompatibleProvider() + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; configured providers: ollama; fallback includes duckduckgo.', + ) + }) + + test('reports malformed Ollama local configuration in auto mode', () => { + useOpenAICompatibleProvider() + process.env.OLLAMA_BASE_URL = 'not a url' + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search. OLLAMA_BASE_URL is invalid; runtime will skip ollama and fall through to the next provider in auto mode.`, + ) + }) + + test('reports hosted Ollama fallback when its local URL is malformed in auto mode', () => { + useOpenAICompatibleProvider() + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; configured providers: ollama; fallback includes duckduckgo. OLLAMA_BASE_URL is invalid; runtime will skip local Ollama search and use the hosted Ollama API.', + ) + }) + + test.each(['undefined', 'null', 'SUA_CHAVE', ' sua_chave '])( + 'does not report hosted fallback for an %s Ollama key placeholder in auto mode', + placeholder => { + useOpenAICompatibleProvider() + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = placeholder + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search. OLLAMA_BASE_URL is invalid; runtime will skip ollama and fall through to the next provider in auto mode.`, + ) + }, + ) + + test('reports a malformed active Ollama endpoint in auto mode', () => { + useOpenAICompatibleProvider() + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'ftp://ollama.internal/v1' + + expectWebSearchBackend( + true, + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${reliableBackendHint} for reliable search. Active Ollama provider endpoint is invalid; runtime will skip ollama and fall through to the next provider in auto mode.`, + ) + }) + + test('reports hosted fallback for a malformed active Ollama endpoint in auto mode', () => { + useOpenAICompatibleProvider() + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'ftp://ollama.internal/v1' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=auto; configured providers: ollama; fallback includes duckduckgo. Active Ollama provider endpoint is invalid; runtime will skip local Ollama search and use the hosted Ollama API.', + ) + }) + test('fails explicit provider mode when required credentials are missing', () => { process.env.WEB_SEARCH_PROVIDER = 'brave' @@ -500,6 +570,151 @@ describe('system-check WebSearch diagnostics', () => { ) }) + test('passes explicit Ollama mode with the active local provider endpoint', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'https://models.example.com/v1' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; active Ollama provider endpoint configured.', + ) + }) + + test.each(['undefined', 'null'])( + 'uses OPENAI_API_BASE when OPENAI_BASE_URL is the %s placeholder', + placeholder => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = placeholder + process.env.OPENAI_API_BASE = 'http://localhost:11434/v1' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; active Ollama provider endpoint configured.', + ) + }, + ) + + test('passes explicit Ollama mode with local and hosted fallback configuration', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = 'http://localhost:11434' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; OLLAMA_BASE_URL and OLLAMA_API_KEY configured.', + ) + }) + + test('fails explicit Ollama mode without a local endpoint or hosted credential', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=ollama but an active Ollama provider, OLLAMA_BASE_URL, or OLLAMA_API_KEY is missing.', + ) + }) + + test('diagnoses malformed Ollama local configuration without throwing', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = 'not a url' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=ollama but OLLAMA_BASE_URL is not a valid HTTP(S) URL.', + ) + }) + + test('reports hosted fallback when the Ollama local URL is malformed', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; OLLAMA_API_KEY configured; OLLAMA_BASE_URL is invalid and local search will be skipped.', + ) + }) + + test('does not treat a whitespace-only Ollama key as hosted fallback', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = ' ' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=ollama but OLLAMA_BASE_URL is not a valid HTTP(S) URL.', + ) + }) + + test.each(['undefined', 'null', 'SUA_CHAVE', ' sua_chave '])( + 'does not treat an %s Ollama key placeholder as hosted fallback', + placeholder => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = placeholder + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=ollama but OLLAMA_BASE_URL is not a valid HTTP(S) URL.', + ) + }, + ) + + test('does not report whitespace-only Ollama endpoints as configured', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.OLLAMA_BASE_URL = ' ' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = ' ' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; OLLAMA_API_KEY configured.', + ) + }) + + test('diagnoses a malformed active Ollama endpoint without a hosted key', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'ftp://ollama.internal/v1' + + expectWebSearchBackend( + false, + 'WEB_SEARCH_PROVIDER=ollama but active Ollama provider endpoint is not a valid HTTP(S) URL.', + ) + }) + + test('reports hosted fallback for a malformed active Ollama endpoint', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'ftp://ollama.internal/v1' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; OLLAMA_API_KEY configured; active Ollama provider endpoint is invalid and local search will be skipped.', + ) + }) + + test('does not infer an active Ollama endpoint from a hostname substring', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://my-ollama.internal/v1' + process.env.OLLAMA_API_KEY = 'ollama-secret-value-123' + + expectWebSearchBackend( + true, + 'WEB_SEARCH_PROVIDER=ollama; OLLAMA_API_KEY configured.', + ) + }) + test('reports supported native mode without requiring API-backed provider credentials', () => { process.env.WEB_SEARCH_PROVIDER = 'native' diff --git a/scripts/system-check.ts b/scripts/system-check.ts index a19a38ee2..fcd91e5f5 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -46,6 +46,11 @@ import { getProviderMode, type ProviderMode, } from '../src/tools/WebSearchTool/providers/index.js' +import { + getUsableOllamaApiKey, + getUsableOllamaBaseUrlEnvValue, + isOllamaWebSearchBaseUrl, +} from '../src/tools/WebSearchTool/providers/ollama.js' import { getWebSearchTimeoutMs } from '../src/tools/WebSearchTool/providers/timeout.js' import { isFirecrawlCloudApiUrl } from '../src/tools/firecrawl/client.js' import { getAPIProvider } from '../src/utils/model/providers.js' @@ -192,6 +197,7 @@ export function buildMemoryGuardChecks( } const WEB_SEARCH_API_PROVIDER_NAMES = new Set([ + 'ollama', 'firecrawl', 'tavily', 'exa', @@ -204,13 +210,14 @@ const WEB_SEARCH_API_PROVIDER_NAMES = new Set([ ]) const WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT = - 'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' + 'OLLAMA_BASE_URL, OLLAMA_API_KEY, FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, JINA_API_KEY, BRAVE_API_KEY, BING_API_KEY, MOJEEK_API_KEY, or LINKUP_API_KEY' const WEB_SEARCH_PROVIDER_ENV_VARS: Record< Exclude, string[] > = { custom: ['WEB_SEARCH_API', 'WEB_PROVIDER', 'WEB_URL_TEMPLATE'], + ollama: ['OLLAMA_BASE_URL', 'OLLAMA_API_KEY'], firecrawl: ['FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL'], ddg: [], tavily: ['TAVILY_API_KEY'], @@ -387,6 +394,128 @@ function buildFirecrawlWebSearchCheck(): CheckResult { ) } +function isInvalidOllamaBaseUrl(value: string | undefined): boolean { + const trimmed = value?.trim() + if (!trimmed) return false + + try { + const parsed = new URL(trimmed) + return parsed.protocol !== 'http:' && parsed.protocol !== 'https:' + } catch { + return true + } +} + +type OllamaLocalConfig = + | { status: 'absent' } + | { + status: 'configured' | 'invalid' + source: 'explicit' | 'active' + } + +function getOllamaLocalConfig(): OllamaLocalConfig { + const explicitBaseUrl = getUsableOllamaBaseUrlEnvValue( + process.env.OLLAMA_BASE_URL, + ) + if (explicitBaseUrl) { + return { + status: isInvalidOllamaBaseUrl(explicitBaseUrl) + ? 'invalid' + : 'configured', + source: 'explicit', + } + } + + if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + return { status: 'absent' } + } + + const activeBaseUrl = + getUsableOllamaBaseUrlEnvValue(process.env.OPENAI_BASE_URL) ?? + getUsableOllamaBaseUrlEnvValue(process.env.OPENAI_API_BASE) + if (!activeBaseUrl) return { status: 'absent' } + + const markedOllamaRoute = + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim().toLowerCase() === 'ollama' + if (!markedOllamaRoute && !isOllamaWebSearchBaseUrl(activeBaseUrl)) { + return { status: 'absent' } + } + + return { + status: isInvalidOllamaBaseUrl(activeBaseUrl) ? 'invalid' : 'configured', + source: 'active', + } +} + +function buildOllamaWebSearchCheck(providerConfigured: boolean): CheckResult { + const ollamaApiKey = getUsableOllamaApiKey(process.env.OLLAMA_API_KEY) + const localConfig = getOllamaLocalConfig() + if (localConfig.status === 'invalid') { + const configLabel = + localConfig.source === 'explicit' + ? 'OLLAMA_BASE_URL' + : 'active Ollama provider endpoint' + if (ollamaApiKey) { + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=ollama; OLLAMA_API_KEY configured; ${configLabel} is invalid and local search will be skipped.`, + ) + } + return fail( + 'Web search backend', + `WEB_SEARCH_PROVIDER=ollama but ${configLabel} is not a valid HTTP(S) URL.`, + ) + } + + if (!providerConfigured) { + return fail( + 'Web search backend', + 'WEB_SEARCH_PROVIDER=ollama but an active Ollama provider, OLLAMA_BASE_URL, or OLLAMA_API_KEY is missing.', + ) + } + + const configured: string[] = [] + if (localConfig.status === 'configured') { + configured.push( + localConfig.source === 'explicit' + ? 'OLLAMA_BASE_URL' + : 'active Ollama provider endpoint', + ) + } + if (ollamaApiKey) configured.push('OLLAMA_API_KEY') + + return pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=ollama; ${formatAndList(configured)} configured.`, + ) +} + +function getAutoOllamaConfigDetail(): string | undefined { + const localConfig = getOllamaLocalConfig() + if (localConfig.status !== 'invalid') return undefined + + const configLabel = + localConfig.source === 'explicit' + ? 'OLLAMA_BASE_URL' + : 'Active Ollama provider endpoint' + + if (getUsableOllamaApiKey(process.env.OLLAMA_API_KEY)) { + return `${configLabel} is invalid; runtime will skip local Ollama search and use the hosted Ollama API.` + } + + return `${configLabel} is invalid; runtime will skip ollama and fall through to the next provider in auto mode.` +} + +function appendAutoOllamaConfigDetail(result: CheckResult): CheckResult { + const ollamaDetail = getAutoOllamaConfigDetail() + if (!ollamaDetail) return result + + return { + ...result, + detail: result.detail ? `${result.detail} ${ollamaDetail}` : ollamaDetail, + } +} + function getAutoFirecrawlMissingCredentialDetail(): string | undefined { const firecrawlSelectedByAutoChain = getAvailableProviders() .some(provider => provider.name === 'firecrawl') @@ -512,9 +641,11 @@ function buildWebSearchEnvChecks(): CheckResult[] { if (configuredProviders.length > 0) { return appendWebSearchTimeoutDetails([ appendAutoFirecrawlMissingCredentialDetail( - pass( - 'Web search backend', - `WEB_SEARCH_PROVIDER=auto; configured providers: ${configuredProviders.join(', ')}; fallback includes duckduckgo.`, + appendAutoOllamaConfigDetail( + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; configured providers: ${configuredProviders.join(', ')}; fallback includes duckduckgo.`, + ), ), ), ]) @@ -522,9 +653,11 @@ function buildWebSearchEnvChecks(): CheckResult[] { return appendWebSearchTimeoutDetails([ appendAutoFirecrawlMissingCredentialDetail( - pass( - 'Web search backend', - `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.`, + appendAutoOllamaConfigDetail( + pass( + 'Web search backend', + `WEB_SEARCH_PROVIDER=auto; only DuckDuckGo fallback is available. DuckDuckGo scraping can be rate-limited from datacenter/VPN/repeated-request networks. Configure ${WEB_SEARCH_RELIABLE_BACKEND_ENV_HINT} for reliable search.`, + ), ), ), ]) @@ -548,6 +681,10 @@ function buildWebSearchEnvChecks(): CheckResult[] { return appendWebSearchTimeoutDetails([buildFirecrawlWebSearchCheck()]) } + if (mode === 'ollama') { + return appendWebSearchTimeoutDetails([buildOllamaWebSearchCheck(providerConfigured)]) + } + if (mode === 'custom') { return [buildCustomWebSearchCheck(providerConfigured)] } diff --git a/src/__tests__/providerCounts.test.ts b/src/__tests__/providerCounts.test.ts index 8aa46fcab..a6c2510d8 100644 --- a/src/__tests__/providerCounts.test.ts +++ b/src/__tests__/providerCounts.test.ts @@ -18,6 +18,7 @@ describe('Provider result counts', () => { 'you.ts', 'jina.ts', 'duckduckgo.ts', + 'ollama.ts', // linkup.ts excluded — uses depth param, not a result count field ] diff --git a/src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md b/src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md index 1fac912d4..745b017da 100644 --- a/src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md +++ b/src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md @@ -7,6 +7,7 @@ OpenClaude supports multiple search backends through a provider adapter system. | Provider | Env Var | Auth Header | Method | |---|---|---|---| | Custom API | `WEB_SEARCH_API` | Configurable | GET/POST | +| Ollama | Active Ollama route or `OLLAMA_API_KEY` | Local sign-in / `Authorization: Bearer` | POST | | SearXNG | `WEB_PROVIDER=searxng` | — | GET | | Google | `WEB_PROVIDER=google` + `GOOGLE_CSE_ID` | *(query param `?key=`)* | GET | | Brave (preset) | `WEB_PROVIDER=brave` | `X-Subscription-Token` | GET | @@ -49,6 +50,7 @@ export WEB_SEARCH_API=https://search.example.com/search | Mode | Behavior | |---|---| | `auto` (default) | Try all configured providers in order, fall through on failure | +| `ollama` | Local signed-in Ollama, then hosted Ollama when `OLLAMA_API_KEY` is set; throws if both fail | | `tavily` | Tavily only — throws on failure | | `exa` | Exa only — throws on failure | | `brave` | Brave only — throws on failure | @@ -57,7 +59,7 @@ export WEB_SEARCH_API=https://search.example.com/search | `ddg` | DuckDuckGo only — throws on failure | | `native` | Anthropic native / Codex only | -**Auto mode priority:** firecrawl → tavily → exa → you → jina → brave → bing → mojeek → linkup → ddg +**Auto mode priority:** ollama → firecrawl → tavily → exa → you → jina → brave → bing → mojeek → linkup → ddg > **Note:** The `custom` provider is excluded from the `auto` chain. It is only used when `WEB_SEARCH_PROVIDER=custom` is explicitly set. This prevents the generic outbound provider from silently becoming the default backend. @@ -81,6 +83,27 @@ Invalid, fractional, zero, negative, or very large values fall back to 15s. Cust ## Provider Request & Response Formats +### Ollama + +When the active route resolves to Ollama, OpenClaude first calls the configured server's signed-in proxy: + +```text +POST http://localhost:11434/api/experimental/web_search +Content-Type: application/json + +{"query":"search terms","max_results":10} +``` + +If that request fails and `OLLAMA_API_KEY` is set, it retries the hosted API with `Authorization: Bearer $OLLAMA_API_KEY`. The key is never sent to the local server. + +```text +POST https://ollama.com/api/web_search +Authorization: Bearer $OLLAMA_API_KEY +Content-Type: application/json +``` + +Both endpoints return structured `results` containing `title`, `url`, and `content`. Use `WEB_SEARCH_PROVIDER=ollama` to select only this local-to-hosted Ollama chain; in `auto` mode, another configured search backend is tried if Ollama search fails. + ### Tavily ```bash diff --git a/src/tools/WebSearchTool/WebSearchTool.test.ts b/src/tools/WebSearchTool/WebSearchTool.test.ts index 49d1a646b..780ef64d4 100644 --- a/src/tools/WebSearchTool/WebSearchTool.test.ts +++ b/src/tools/WebSearchTool/WebSearchTool.test.ts @@ -18,6 +18,8 @@ describe('buildEmptyAdapterResultHint', () => { test('includes the actionable env-var list so the user can pick one', () => { const msg = buildEmptyAdapterResultHint('moonshot', 'duckduckgo') for (const key of [ + 'OLLAMA_BASE_URL', + 'OLLAMA_API_KEY', 'FIRECRAWL_API_KEY', 'TAVILY_API_KEY', 'EXA_API_KEY', diff --git a/src/tools/WebSearchTool/WebSearchTool.ts b/src/tools/WebSearchTool/WebSearchTool.ts index 4e68b6954..132468e02 100644 --- a/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/tools/WebSearchTool/WebSearchTool.ts @@ -122,7 +122,7 @@ function buildEmptyAdapterResultHint(provider: string, providerName: string): st `No results from "${providerName}" search backend for provider "${provider}". ` + `The default DuckDuckGo backend is rate-limited from many networks (datacenter IPs, VPNs, repeated requests) and returns 0 results when blocked. ` + `For reliable web search on this provider, set one of: ` + - `FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY, YOU_API_KEY — ` + + `OLLAMA_BASE_URL, OLLAMA_API_KEY, FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY, YOU_API_KEY — ` + `or switch to an Anthropic / Vertex / Foundry provider that supports the native web_search tool.` ) } diff --git a/src/tools/WebSearchTool/providers/duckduckgo.test.ts b/src/tools/WebSearchTool/providers/duckduckgo.test.ts index 97017c3cf..c7b0df156 100644 --- a/src/tools/WebSearchTool/providers/duckduckgo.test.ts +++ b/src/tools/WebSearchTool/providers/duckduckgo.test.ts @@ -40,6 +40,24 @@ describe('DuckDuckGo SafeSearchType', () => { }) describe('duckduckgoProvider retry cancellation', () => { + test('anomaly hint includes local and hosted Ollama configuration', async () => { + mock.module('duck-duck-scrape', () => ({ + SafeSearchType: { + STRICT: 0, + MODERATE: -1, + OFF: -2, + }, + search: async () => { + throw new Error('anomaly in the request') + }, + })) + + const { duckduckgoProvider } = await import('./duckduckgo.js') + await expect( + duckduckgoProvider.search({ query: 'blocked search' }), + ).rejects.toThrow(/OLLAMA_BASE_URL, OLLAMA_API_KEY/) + }) + test('provider-level timeout stops after one scrape attempt', async () => { process.env.WEB_SEARCH_TIMEOUT_SEC = '1' diff --git a/src/tools/WebSearchTool/providers/duckduckgo.ts b/src/tools/WebSearchTool/providers/duckduckgo.ts index 5ed8cd460..78e1c8bc2 100644 --- a/src/tools/WebSearchTool/providers/duckduckgo.ts +++ b/src/tools/WebSearchTool/providers/duckduckgo.ts @@ -13,7 +13,7 @@ import { const DDG_ANOMALY_HINT = 'DuckDuckGo scraping is rate-limited from this network. ' + 'Configure a search backend with one of: ' + - 'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, ' + + 'OLLAMA_BASE_URL, OLLAMA_API_KEY, FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, ' + 'JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY — ' + 'or use an Anthropic / Vertex / Foundry provider for native web search.' diff --git a/src/tools/WebSearchTool/providers/index.test.ts b/src/tools/WebSearchTool/providers/index.test.ts index 73a41dcae..8d520161c 100644 --- a/src/tools/WebSearchTool/providers/index.test.ts +++ b/src/tools/WebSearchTool/providers/index.test.ts @@ -19,6 +19,12 @@ const savedWebSearchEnv = { BING_API_KEY: process.env.BING_API_KEY, MOJEEK_API_KEY: process.env.MOJEEK_API_KEY, LINKUP_API_KEY: process.env.LINKUP_API_KEY, + OLLAMA_API_KEY: process.env.OLLAMA_API_KEY, + OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL, + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + CLAUDE_CODE_PROVIDER_ROUTE_ID: process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID, } const originalFetch = globalThis.fetch @@ -61,6 +67,12 @@ function configureAutoModeWithOnlyBrave(): void { delete process.env.BING_API_KEY delete process.env.MOJEEK_API_KEY delete process.env.LINKUP_API_KEY + delete process.env.OLLAMA_API_KEY + delete process.env.OLLAMA_BASE_URL + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_BASE + delete process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID } function mockDuckDuckGoSearch( @@ -105,6 +117,11 @@ describe('getProviderMode', () => { expect(getProviderMode()).toBe('ddg') }) + test('returns ollama mode', () => { + process.env.WEB_SEARCH_PROVIDER = 'ollama' + expect(getProviderMode()).toBe('ollama') + }) + test('returns native mode', () => { process.env.WEB_SEARCH_PROVIDER = 'native' expect(getProviderMode()).toBe('native') @@ -151,6 +168,12 @@ describe('getProviderChain', () => { expect(chain[0].name).toBe('duckduckgo') }) + test('ollama mode returns the Ollama provider', () => { + const chain = getProviderChain('ollama' as ProviderMode) + expect(chain).toHaveLength(1) + expect(chain[0].name).toBe('ollama') + }) + test('native mode returns empty chain', () => { expect(getProviderChain('native')).toHaveLength(0) }) @@ -246,6 +269,60 @@ describe('runSearch', () => { expect(output.hits[0].title).toBe('Body fallback result') }) + test('active Ollama search falls through to DuckDuckGo when Ollama is unavailable', async () => { + process.env.WEB_SEARCH_PROVIDER = 'auto' + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + delete process.env.OLLAMA_API_KEY + delete process.env.OLLAMA_BASE_URL + delete process.env.FIRECRAWL_API_KEY + delete process.env.FIRECRAWL_API_URL + delete process.env.TAVILY_API_KEY + delete process.env.EXA_API_KEY + delete process.env.YOU_API_KEY + delete process.env.JINA_API_KEY + delete process.env.BRAVE_API_KEY + delete process.env.BING_API_KEY + delete process.env.MOJEEK_API_KEY + delete process.env.LINKUP_API_KEY + console.error = () => {} + + let ollamaCalls = 0 + globalThis.fetch = (async () => { + ollamaCalls++ + return new Response('not signed in', { status: 401 }) + }) as unknown as typeof fetch + mockDuckDuckGoSearch(async () => ({ + results: [ + { + title: 'DuckDuckGo fallback', + url: 'https://example.com/ddg', + }, + ], + })) + + const { runSearch } = await import('./index.js') + const output = await runSearch({ query: 'ollama fallback' }) + + expect(ollamaCalls).toBe(1) + expect(output.providerName).toBe('duckduckgo') + expect(output.hits[0]?.title).toBe('DuckDuckGo fallback') + }) + + test('malformed local Ollama configuration falls through to DuckDuckGo', async () => { + process.env.WEB_SEARCH_PROVIDER = 'auto' + process.env.OLLAMA_BASE_URL = 'not a url' + delete process.env.OLLAMA_API_KEY + mockDuckDuckGoSearch(async () => ({ + results: [{ title: 'Fallback', url: 'https://example.com/fallback' }], + })) + + const { runSearch } = await import('./index.js') + const output = await runSearch({ query: 'invalid Ollama URL' }) + + expect(output.providerName).toBe('duckduckgo') + }) + test('auto mode does not fall through after caller abort', async () => { configureAutoModeWithOnlyBrave() process.env.WEB_SEARCH_TIMEOUT_SEC = '1' @@ -285,6 +362,12 @@ describe('getAvailableProviders', () => { expect(providers.some(p => p.name === 'duckduckgo')).toBe(true) }) + test('auto mode puts Ollama first when its API key is configured', () => { + process.env.OLLAMA_API_KEY = 'ollama-test-key' + const providers = getAvailableProviders() + expect(providers[0]?.name).toBe('ollama') + }) + test('does NOT include custom in available providers (auto chain)', () => { const providers = getAvailableProviders() expect(providers.some(p => p.name === 'custom')).toBe(false) diff --git a/src/tools/WebSearchTool/providers/index.ts b/src/tools/WebSearchTool/providers/index.ts index dc3dc2f05..35e89970c 100644 --- a/src/tools/WebSearchTool/providers/index.ts +++ b/src/tools/WebSearchTool/providers/index.ts @@ -5,6 +5,7 @@ * * "auto" (default) — try providers in priority order, fall through on failure * "custom" — use WEB_SEARCH_API / WEB_PROVIDER preset only (fail loudly) + * "ollama" — use Ollama local/hosted Web Search API only (fail loudly) * "firecrawl" — use Firecrawl only (fail loudly) * "tavily" — use Tavily only (fail loudly) * "exa" — use Exa only (fail loudly) @@ -38,6 +39,7 @@ import { braveProvider } from './brave.js' import { bingProvider } from './bing.js' import { mojeekProvider } from './mojeek.js' import { linkupProvider } from './linkup.js' +import { ollamaProvider } from './ollama.js' export { type SearchInput, type SearchProvider, type ProviderOutput, type SearchHit } from './types.js' export { applyDomainFilters, safeHostname, hostMatchesDomain } from './types.js' @@ -46,7 +48,7 @@ export { extractHits } from './custom.js' // --------------------------------------------------------------------------- // All registered providers — order matters for auto mode // --------------------------------------------------------------------------- -// Priority: firecrawl → tavily → exa → you → jina → brave → bing → mojeek → linkup → ddg +// Priority: ollama → firecrawl → tavily → exa → you → jina → brave → bing → mojeek → linkup → ddg // DDG is last because it's free but rate-limited. // Brave sits ahead of Bing because it runs an independent index (not Google/Bing // dependent) and has a usable free tier; Bing's hosted API was sunsetted in 2025 @@ -56,6 +58,7 @@ export { extractHits } from './custom.js' // This prevents the generic outbound provider from silently becoming the default backend. const ALL_PROVIDERS: SearchProvider[] = [ + ollamaProvider, firecrawlProvider, tavilyProvider, exaProvider, @@ -79,6 +82,7 @@ export function getAvailableProviders(): SearchProvider[] { export type ProviderMode = | 'auto' | 'custom' + | 'ollama' | 'firecrawl' | 'ddg' | 'tavily' @@ -93,6 +97,7 @@ export type ProviderMode = const PROVIDER_BY_NAME: Record = { custom: customProvider, + ollama: ollamaProvider, firecrawl: firecrawlProvider, ddg: duckduckgoProvider, tavily: tavilyProvider, diff --git a/src/tools/WebSearchTool/providers/ollama.test.ts b/src/tools/WebSearchTool/providers/ollama.test.ts new file mode 100644 index 000000000..5a0dd1d8c --- /dev/null +++ b/src/tools/WebSearchTool/providers/ollama.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../../test/sharedMutationLock.js' +import { ollamaProvider } from './ollama.js' + +const originalFetch = globalThis.fetch +const savedEnv = { + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + CLAUDE_CODE_PROVIDER_ROUTE_ID: process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID, + OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL, + OLLAMA_API_KEY: process.env.OLLAMA_API_KEY, +} + +function restoreEnv(): void { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} + +function clearOllamaEnv(): void { + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_API_BASE + delete process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID + delete process.env.OLLAMA_BASE_URL + delete process.env.OLLAMA_API_KEY +} + +beforeEach(async () => { + await acquireSharedMutationLock('WebSearchTool/providers/ollama.test.ts') + clearOllamaEnv() +}) + +afterEach(() => { + try { + restoreEnv() + globalThis.fetch = originalFetch + } finally { + releaseSharedMutationLock() + } +}) + +describe('ollamaProvider', () => { + test('is configured for the active Ollama route or a hosted API key', () => { + expect(ollamaProvider.isConfigured()).toBe(false) + + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + expect(ollamaProvider.isConfigured()).toBe(true) + + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.OPENAI_BASE_URL + process.env.OLLAMA_API_KEY = 'ollama-test-key' + expect(ollamaProvider.isConfigured()).toBe(true) + }) + + test.each(['undefined', 'null', 'SUA_CHAVE', ' sua_chave '])( + 'does not configure hosted search for an %s API key placeholder', + async placeholder => { + process.env.OLLAMA_API_KEY = placeholder + let calls = 0 + globalThis.fetch = (async () => { + calls++ + return Response.json({ results: [] }) + }) as unknown as typeof fetch + + expect(ollamaProvider.isConfigured()).toBe(false) + await expect( + ollamaProvider.search({ query: 'placeholder key' }), + ).rejects.toThrow('OLLAMA_API_KEY') + expect(calls).toBe(0) + }, + ) + + test('uses the Ollama route marker for a reverse-proxied active profile', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = 'https://models.example.com/v1' + + expect(ollamaProvider.isConfigured()).toBe(true) + }) + + test.each(['undefined', 'null'])( + 'falls back to OPENAI_API_BASE when OPENAI_BASE_URL is %s', + placeholder => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + process.env.OPENAI_BASE_URL = placeholder + process.env.OPENAI_API_BASE = 'http://localhost:11434/v1' + + expect(ollamaProvider.isConfigured()).toBe(true) + }, + ) + + test('does not infer Ollama from an unrelated hostname substring', () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.notollama.example/v1' + + expect(ollamaProvider.isConfigured()).toBe(false) + + process.env.OPENAI_BASE_URL = 'https://ollama.internal/v1' + expect(ollamaProvider.isConfigured()).toBe(true) + }) + + test('uses the signed-in local endpoint and maps structured results', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + + let requestUrl = '' + let requestInit: RequestInit | undefined + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input) + requestInit = init + return Response.json({ + results: [ + { + title: 'Ollama result', + url: 'https://docs.ollama.com/capabilities/web-search', + content: 'Structured result content', + }, + ], + }) + }) as typeof fetch + + const output = await ollamaProvider.search({ query: 'ollama search' }) + + expect(requestUrl).toBe( + 'http://localhost:11434/api/experimental/web_search', + ) + expect(requestInit?.headers).toEqual({ + 'Content-Type': 'application/json', + }) + expect(JSON.parse(String(requestInit?.body))).toEqual({ + query: 'ollama search', + max_results: 10, + }) + expect(output.providerName).toBe('ollama') + expect(output.hits).toEqual([ + { + title: 'Ollama result', + url: 'https://docs.ollama.com/capabilities/web-search', + description: 'Structured result content', + source: 'docs.ollama.com', + }, + ]) + }) + + test('falls back from local Ollama to the authenticated hosted API', async () => { + process.env.OLLAMA_BASE_URL = 'http://ollama.internal:11434/v1/' + process.env.OLLAMA_API_KEY = 'ollama-test-key' + + const requests: Array<{ url: string; headers: Headers }> = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + headers: new Headers(init?.headers), + }) + if (requests.length === 1) { + return Response.json({ results: [] }) + } + return Response.json({ + results: [ + { + title: 'Hosted result', + url: 'https://example.com/result', + content: 'Hosted content', + }, + ], + }) + }) as typeof fetch + + const output = await ollamaProvider.search({ query: 'fallback' }) + + expect(requests.map(request => request.url)).toEqual([ + 'http://ollama.internal:11434/api/experimental/web_search', + 'https://ollama.com/api/web_search', + ]) + expect(requests[0].headers.has('Authorization')).toBe(false) + expect(requests[1].headers.get('Authorization')).toBe( + 'Bearer ollama-test-key', + ) + expect(output.hits[0]?.title).toBe('Hosted result') + }) + + test('skips a malformed local URL and uses the hosted API', async () => { + process.env.OLLAMA_BASE_URL = 'not a url' + process.env.OLLAMA_API_KEY = 'ollama-test-key' + + let requestUrl = '' + globalThis.fetch = (async (input: RequestInfo | URL) => { + requestUrl = String(input) + return Response.json({ + results: [{ title: 'Hosted', url: 'https://example.com/hosted' }], + }) + }) as typeof fetch + + const output = await ollamaProvider.search({ query: 'fallback' }) + + expect(requestUrl).toBe('https://ollama.com/api/web_search') + expect(output.hits[0]?.title).toBe('Hosted') + }) + + test('reports malformed local-only configuration without throwing from discovery', async () => { + process.env.OLLAMA_BASE_URL = 'not a url' + + expect(ollamaProvider.isConfigured()).toBe(false) + await expect( + ollamaProvider.search({ query: 'invalid local' }), + ).rejects.toThrow('configured endpoint is not a valid HTTP(S) URL') + }) + + test('applies shared domain filters to Ollama results', async () => { + process.env.OLLAMA_API_KEY = 'ollama-test-key' + globalThis.fetch = (async () => + Response.json({ + results: [ + { title: 'Keep', url: 'https://docs.ollama.com/search', content: 'a' }, + { title: 'Drop', url: 'https://example.com/search', content: 'b' }, + ], + })) as unknown as typeof fetch + + const output = await ollamaProvider.search({ + query: 'domains', + allowed_domains: ['ollama.com'], + }) + + expect(output.hits.map(hit => hit.title)).toEqual(['Keep']) + }) + + test('does not try hosted fallback after caller cancellation', async () => { + process.env.OLLAMA_BASE_URL = 'http://localhost:11434' + process.env.OLLAMA_API_KEY = 'ollama-test-key' + const controller = new AbortController() + let calls = 0 + globalThis.fetch = (async () => { + calls++ + controller.abort() + throw new DOMException('Aborted', 'AbortError') + }) as unknown as typeof fetch + + await expect( + ollamaProvider.search({ query: 'cancel' }, controller.signal), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(calls).toBe(1) + }) +}) diff --git a/src/tools/WebSearchTool/providers/ollama.ts b/src/tools/WebSearchTool/providers/ollama.ts new file mode 100644 index 000000000..037c43c32 --- /dev/null +++ b/src/tools/WebSearchTool/providers/ollama.ts @@ -0,0 +1,230 @@ +/** + * Ollama Web Search API adapter. + * + * Signed-in local Ollama servers proxy search through the experimental local + * endpoint. An API key enables the hosted endpoint as a fallback. + */ + +import type { SearchInput, SearchProvider } from './types.js' +import { applyDomainFilters, safeHostname, type ProviderOutput } from './types.js' +import { fetchJsonWithWebSearchTimeout } from './timeout.js' +import { sanitizeApiKey } from '../../../utils/providerSecrets.js' + +const OLLAMA_HOSTED_WEB_SEARCH_URL = 'https://ollama.com/api/web_search' + +type OllamaSearchTarget = { + label: string + url: string + authorization?: string +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +export function getUsableOllamaBaseUrlEnvValue( + value: string | undefined, +): string | undefined { + const trimmed = nonEmpty(value) + if (!trimmed) return undefined + const normalized = trimmed.toLowerCase() + return normalized === 'undefined' || normalized === 'null' + ? undefined + : trimmed +} + +export function getUsableOllamaApiKey( + value: string | undefined, +): string | undefined { + return sanitizeApiKey(value)?.trim() +} + +function isTruthyEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return Boolean( + normalized && + normalized !== '0' && + normalized !== 'false' && + normalized !== 'no', + ) +} + +export function isOllamaWebSearchBaseUrl(value: string | undefined): boolean { + const trimmed = getUsableOllamaBaseUrlEnvValue(value) + if (!trimmed) return false + + try { + const parsed = new URL(trimmed) + const host = parsed.host.toLowerCase() + const hostnameLabels = parsed.hostname.toLowerCase().split('.') + const pathSegments = parsed.pathname + .toLowerCase() + .split('/') + .filter(Boolean) + return ( + host.endsWith(':11434') || + hostnameLabels.includes('ollama') || + pathSegments.includes('ollama') + ) + } catch { + return false + } +} + +function normalizeOllamaApiBaseUrl(value: string): string { + const parsed = new URL(value) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('configured endpoint is not a valid HTTP(S) URL') + } + const pathname = parsed.pathname.replace(/\/+$/, '') + parsed.pathname = pathname.endsWith('/v1') + ? pathname.slice(0, -3) || '/' + : pathname || '/' + parsed.search = '' + parsed.hash = '' + return parsed.toString().replace(/\/+$/, '') +} + +function isAbortError(error: unknown, signal?: AbortSignal): boolean { + return ( + signal?.aborted === true || + (error instanceof Error && error.name === 'AbortError') + ) +} + +function getConfiguredLocalBaseUrl(): string | undefined { + const explicitOllamaBaseUrl = getUsableOllamaBaseUrlEnvValue( + process.env.OLLAMA_BASE_URL, + ) + if (explicitOllamaBaseUrl) return explicitOllamaBaseUrl + + if (!isTruthyEnv(process.env.CLAUDE_CODE_USE_OPENAI)) return undefined + + const openAIBaseUrl = + getUsableOllamaBaseUrlEnvValue(process.env.OPENAI_BASE_URL) ?? + getUsableOllamaBaseUrlEnvValue(process.env.OPENAI_API_BASE) + const markedOllamaRoute = + nonEmpty(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID)?.toLowerCase() === 'ollama' + if (!markedOllamaRoute && !isOllamaWebSearchBaseUrl(openAIBaseUrl)) { + return undefined + } + + return openAIBaseUrl +} + +function getSearchTargets(): { + targets: OllamaSearchTarget[] + errors: string[] +} { + const targets: OllamaSearchTarget[] = [] + const errors: string[] = [] + const localBaseUrl = getConfiguredLocalBaseUrl() + const apiKey = getUsableOllamaApiKey(process.env.OLLAMA_API_KEY) + + if (localBaseUrl) { + try { + targets.push({ + label: 'local', + url: `${normalizeOllamaApiBaseUrl(localBaseUrl)}/api/experimental/web_search`, + }) + } catch { + errors.push('local: configured endpoint is not a valid HTTP(S) URL') + } + } + + if (apiKey) { + targets.push({ + label: 'hosted', + url: OLLAMA_HOSTED_WEB_SEARCH_URL, + authorization: `Bearer ${apiKey}`, + }) + } + + return { targets, errors } +} + +export const ollamaProvider: SearchProvider = { + name: 'ollama', + + isConfigured() { + return getSearchTargets().targets.length > 0 + }, + + async search(input: SearchInput, signal?: AbortSignal): Promise { + const start = performance.now() + const { targets, errors } = getSearchTargets() + + if (targets.length === 0) { + if (errors.length > 0) { + throw new Error(`Ollama web search failed (${errors.join('; ')})`) + } + throw new Error( + 'Ollama search requires an active Ollama provider, OLLAMA_BASE_URL, or OLLAMA_API_KEY.', + ) + } + + for (const [targetIndex, target] of targets.entries()) { + try { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (target.authorization) { + headers.Authorization = target.authorization + } + + const data = await fetchJsonWithWebSearchTimeout( + target.url, + { + method: 'POST', + headers, + body: JSON.stringify({ + query: input.query, + max_results: 10, + }), + }, + signal, + { providerName: `Ollama ${target.label}` }, + ) + + if (!data || !Array.isArray(data.results)) { + throw new Error('response did not contain a results array') + } + + const hits = data.results + .filter((result: unknown): result is Record => + Boolean(result) && typeof result === 'object', + ) + .map(result => { + const title = typeof result.title === 'string' ? result.title : '' + const url = typeof result.url === 'string' ? result.url : '' + const content = + typeof result.content === 'string' ? result.content : undefined + return { + title: title || url, + url, + description: content, + source: safeHostname(url), + } + }) + .filter(hit => Boolean(hit.title && hit.url)) + + if (hits.length === 0 && targetIndex < targets.length - 1) { + throw new Error('response contained no usable results') + } + + return { + hits: applyDomainFilters(hits, input), + providerName: 'ollama', + durationSeconds: (performance.now() - start) / 1000, + } + } catch (error) { + if (isAbortError(error, signal)) throw error + const message = error instanceof Error ? error.message : String(error) + errors.push(`${target.label}: ${message}`) + } + } + + throw new Error(`Ollama web search failed (${errors.join('; ')})`) + }, +} diff --git a/src/utils/envFile.test.ts b/src/utils/envFile.test.ts index 741b42761..1e1bc2b08 100644 --- a/src/utils/envFile.test.ts +++ b/src/utils/envFile.test.ts @@ -30,6 +30,8 @@ const TEST_ENV_KEYS = [ 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'OPENCLAUDE_OLLAMA_NUM_CTX', + 'OLLAMA_API_KEY', + 'OLLAMA_BASE_URL', 'WEB_AUTH_HEADER', 'WEB_AUTH_SCHEME', 'WEB_BODY_TEMPLATE', @@ -387,6 +389,22 @@ describe('loadEnvFile', () => { }) }) + it('loads documented Ollama web search values', () => { + const filePath = writeTempEnvFile([ + 'OLLAMA_API_KEY=ollama-key', + 'OLLAMA_BASE_URL=http://localhost:11434', + 'WEB_SEARCH_PROVIDER=ollama', + ].join('\n')) + + const loaded = loadEnvFile(filePath) + + expect(loaded).toEqual({ + OLLAMA_API_KEY: 'ollama-key', + OLLAMA_BASE_URL: 'http://localhost:11434', + WEB_SEARCH_PROVIDER: 'ollama', + }) + }) + it('loads documented custom web search and Codex auth-file setup values', () => { const filePath = writeTempEnvFile([ 'WEB_SEARCH_PROVIDER=custom', diff --git a/src/utils/envFile.ts b/src/utils/envFile.ts index 664dd4674..a845cdc76 100644 --- a/src/utils/envFile.ts +++ b/src/utils/envFile.ts @@ -99,6 +99,8 @@ const ALLOWED_ENV_FILE_KEYS = new Set([ 'OPENGATEWAY_API_KEY', 'OPENGATEWAY_BASE_URL', 'OPENCLAUDE_OLLAMA_NUM_CTX', + 'OLLAMA_API_KEY', + 'OLLAMA_BASE_URL', 'OPENROUTER_API_KEY', 'OPENAI_API_BASE', 'OPENAI_API_FORMAT', diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 4148c5863..310797a72 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -87,6 +87,7 @@ test('matching persisted ollama env is reused for ollama launch', async () => { assert.equal(env.OPENAI_BASE_URL, 'http://127.0.0.1:11435/v1') assert.equal(env.OPENAI_MODEL, 'mistral:7b-instruct') + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'ollama') }) test('ollama launch ignores mismatched persisted openai env and shell model fallback', async () => { @@ -111,6 +112,7 @@ test('ollama launch ignores mismatched persisted openai env and shell model fall assert.equal(env.OPENAI_BASE_URL, 'http://localhost:11434/v1') assert.equal(env.OPENAI_MODEL, 'qwen2.5-coder:7b') + assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'ollama') assert.equal(env.OPENAI_API_KEY, undefined) assert.equal(env.CODEX_API_KEY, undefined) assert.equal(env.CHATGPT_ACCOUNT_ID, undefined) @@ -1576,6 +1578,7 @@ test('ollama profiles never persist openai api keys', () => { assert.deepEqual(env, { OPENAI_BASE_URL: 'http://localhost:11434/v1', OPENAI_MODEL: 'llama3.1:8b', + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'ollama', }) assert.equal('OPENAI_API_KEY' in env, false) }) diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 1ca51e222..243e5a701 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -405,6 +405,7 @@ export function buildOllamaProfileEnv( return { OPENAI_BASE_URL: options.getOllamaChatBaseUrl(options.baseUrl ?? undefined), OPENAI_MODEL: model, + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'ollama', } } @@ -1982,6 +1983,7 @@ export async function buildLaunchEnv(options: { OPENAI_MODEL: persistedOpenAIModel || (await resolveOllamaModel(options.goal)), + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'ollama', }, }) } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index d4848f920..a6ca467a0 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -343,7 +343,31 @@ function buildCloudflareProfile(overrides: Partial = {}): Provi }) } +function buildOllamaProfile( + overrides: Partial = {}, +): ProviderProfile { + return buildProfile({ + provider: 'ollama', + name: 'Ollama', + baseUrl: 'https://models.example.com/v1', + model: 'deepseek-v4-flash:cloud', + apiKey: '', + ...overrides, + }) +} + describe('applyProviderProfileToProcessEnv', () => { + test('marks a reverse-proxied Ollama profile for runtime discovery', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv(buildOllamaProfile()) + + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('https://models.example.com/v1') + expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('ollama') + }, 20_000) + test('LLMTR profile clears an ambient dedicated key so its saved key wins', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -3749,6 +3773,41 @@ describe('setActiveProviderProfile', () => { } }) + test('persists the Ollama route marker for a reverse-proxied profile', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) + const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) + process.chdir(tempDir) + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + const { setActiveProviderProfile } = + await importFreshProviderProfileModules() + const ollamaProfile = buildOllamaProfile({ id: 'ollama_proxy_prof' }) + + saveMockGlobalConfig(current => ({ + ...current, + providerProfiles: [ollamaProfile], + })) + + const result = setActiveProviderProfile('ollama_proxy_prof', { configDir }) + const persisted = JSON.parse( + readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'), + ) + + expect(result?.id).toBe('ollama_proxy_prof') + expect(persisted.profile).toBe('openai') + expect(persisted.env).toEqual({ + OPENAI_BASE_URL: 'https://models.example.com/v1', + OPENAI_MODEL: 'deepseek-v4-flash:cloud', + CLAUDE_CODE_PROVIDER_ROUTE_ID: 'ollama', + }) + } finally { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + rmSync(configDir, { recursive: true, force: true }) + } + }) + test('persists primary model for keyed openai-compatible multi-model profiles', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-')) const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-')) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 7a8a92ac7..1603b434b 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -1241,6 +1241,13 @@ export function applyProviderProfileToProcessEnv( openAIProfileEnv.AIMLAPI_API_KEY ?? ambientAimlapiKey } } + // Preserve the selected Ollama route even when the profile points at a + // reverse proxy whose URL has no Ollama-specific hostname or port. Web + // search discovery uses this marker to distinguish that endpoint from a + // generic OpenAI-compatible profile. + if (route.routeId === 'ollama') { + openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + } // Keep ApiSmart route identity even when the profile is retargeted to a // proxy. Dedicated credentials stay withheld above; the route id is what // lets buildLaunchEnv refuse ambient APISMART_API_KEY / mirrored @@ -1643,6 +1650,9 @@ function buildOpenAICompatibleStartupEnv( strictEnv.AIMLAPI_API_KEY = activeProfile.apiKey strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi' } + if (activeProfileRouteId === 'ollama') { + strictEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + } // Atlas Cloud is dedicatedCredentialsOnly: its route ignores // OPENAI_API_KEY, so a generic OpenAI profile pointed at Atlas must // persist the dedicated key too or it relaunches unauthenticated. @@ -1708,6 +1718,9 @@ function buildOpenAICompatibleStartupEnv( if (isAimlapiProfile) { env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi' } + if (activeProfileRouteId === 'ollama') { + env.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'ollama' + } // Preserve ApiSmart identity on retargeted/proxy startup envs so relaunch // withholding can refuse ambient dedicated credentials. Canonical profiles // already stamp this via buildApismartProfileEnv.