mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
+8
-2
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('; ')})`)
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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-'))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user