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
This commit is contained in:
JATMN
2026-09-21 09:10:23 +08:00
committed by GitHub
parent 876fb7924a
commit 70d97fd137
21 changed files with 1088 additions and 16 deletions
+8 -2
View File
@@ -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.
#
+4 -2
View File
@@ -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:
+2
View File
@@ -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`. |
+216 -1
View File
@@ -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'
+144 -7
View File
@@ -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<ProviderMode, 'auto' | 'native'>,
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)]
}
+1
View File
@@ -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
]
@@ -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
@@ -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',
+1 -1
View File
@@ -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.`
)
}
@@ -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'
@@ -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.'
@@ -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)
+6 -1
View File
@@ -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<string, SearchProvider> = {
custom: customProvider,
ollama: ollamaProvider,
firecrawl: firecrawlProvider,
ddg: duckduckgoProvider,
tavily: tavilyProvider,
@@ -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)
})
})
+230
View File
@@ -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<ProviderOutput> {
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<string, string> = {
'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<string, unknown> =>
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('; ')})`)
},
}
+18
View File
@@ -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',
+2
View File
@@ -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',
+3
View File
@@ -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)
})
+2
View File
@@ -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',
},
})
}
+59
View File
@@ -343,7 +343,31 @@ function buildCloudflareProfile(overrides: Partial<ProviderProfile> = {}): Provi
})
}
function buildOllamaProfile(
overrides: Partial<ProviderProfile> = {},
): 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-'))
+13
View File
@@ -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.