Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,13 @@ See [Agent Routing and Step Limits](docs/agent-routing.md) for the full guide.
/search remove-key # remove a saved Exa key
/search status # show which backend handles searches
/search test # run a test search
/search anysearch # select AnySearch (works without a key)
/search key anysearch # optionally add an AnySearch API key in the hidden input
```

Any other backend you configure with a key (Tavily, Brave, Firecrawl, …) runs before the Exa free tier, and DuckDuckGo is the last-resort fallback. When the active provider is Ollama, OpenClaude uses the signed-in local Ollama Web Search endpoint ahead of the free tier. Set `OLLAMA_API_KEY` to enable the hosted Ollama endpoint as a fallback. Set `EXA_FREE_TIER=0` if searches should never go to Exa without a key. See [Web Search Providers](src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md) for every backend and option.
Other backends in the auto chain that you configure with a key (Tavily, Brave, Firecrawl, …) run before the Exa free tier, and DuckDuckGo is the last-resort fallback. When the active provider is Ollama, OpenClaude uses the signed-in local Ollama Web Search endpoint ahead of the free tier. Set `OLLAMA_API_KEY` to enable the hosted Ollama endpoint as a fallback. Set `EXA_FREE_TIER=0` if searches should never go to Exa without a key. See [Web Search Providers](src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md) for every backend and option.

AnySearch is available as an explicit backend with `/search anysearch` or `WEB_SEARCH_PROVIDER=anysearch`. `ANYSEARCH_API_KEY` is optional; anonymous access has lower service limits. Set `ANYSEARCH_MAX_RESULTS` to an integer from 1 to 10 (default 10) to control the requested result count. AnySearch does not change the `auto` search order.

> **Note:** DuckDuckGo fallback works by scraping search results and may be rate-limited, blocked, or subject to DuckDuckGo's Terms of Service.

Expand Down
4 changes: 3 additions & 1 deletion docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,9 @@ search (`/search test`). `/search` saves its choices to the `env` block of

| Variable | Required | Description |
|----------|----------|-------------|
| `WEB_SEARCH_PROVIDER` | No | `auto` (default): keyed Exa first, then any other backend you configured with a key, then the Exa free tier, then DuckDuckGo. Anthropic first-party, Vertex, Foundry, and Codex keep their built-in search in `auto`. Set one backend (`exa`, `tavily`, `brave`, `firecrawl`, `you`, `jina`, `bing`, `mojeek`, `linkup`, `ollama`, `ddg`, `custom`, or `native`) to use only that backend and fail instead of falling back. |
| `WEB_SEARCH_PROVIDER` | No | `auto` (default): keyed Exa first, then other configured backends in the auto chain, then the Exa free tier, then DuckDuckGo. Anthropic first-party, Vertex, Foundry, and Codex keep their built-in search in `auto`. Set one backend (`exa`, `anysearch`, `tavily`, `brave`, `firecrawl`, `you`, `jina`, `bing`, `mojeek`, `linkup`, `ollama`, `ddg`, `custom`, or `native`) to use only that backend and fail instead of falling back. AnySearch is explicit-only and does not join `auto`. |
| `ANYSEARCH_API_KEY` | No | Optional AnySearch API key. Without one, `/search anysearch` sends anonymous requests without an Authorization header. Add or remove a saved key with `/search key anysearch` or `/search remove-key anysearch`. |
| `ANYSEARCH_MAX_RESULTS` | No | Requested AnySearch results, integer 1–10 (default `10`; invalid values use the default). |
| `EXA_API_KEY` | No | Exa API key for higher limits than the free tier. Free keys: https://dashboard.exa.ai/api-keys |
| `EXA_SEARCH_TYPE` | No | Exa search type with a key: `auto` (default), `instant`, `fast`, `deep-lite`, `deep`, or `deep-reasoning`. The deep types can need a higher `WEB_SEARCH_TIMEOUT_SEC`. |
| `EXA_NUM_RESULTS` | No | Results per Exa search, 1–50 (default `15`). |
Expand Down
45 changes: 44 additions & 1 deletion src/commands/search/search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ const memoryStore: SearchEnvStore = {
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'

const ENV_KEYS = ['WEB_SEARCH_PROVIDER', 'EXA_API_KEY', 'TAVILY_API_KEY'] as const
const ENV_KEYS = ['WEB_SEARCH_PROVIDER', 'EXA_API_KEY', 'TAVILY_API_KEY', 'ANYSEARCH_API_KEY'] as const
const savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
const originalFetch = globalThis.fetch

beforeEach(async () => {
await acquireSharedMutationLock('commands/search/search.test.tsx')
Expand All @@ -44,6 +45,7 @@ beforeEach(async () => {
afterEach(() => {
try {
setSearchEnvStoreForTests(undefined)
globalThis.fetch = originalFetch
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
Expand Down Expand Up @@ -184,9 +186,18 @@ describe('/search command', () => {

expect(frame).toContain('Auto (recommended)')
expect(frame).toContain('Exa')
expect(frame).toContain('AnySearch')
expect(frame).toContain('needs TAVILY_API_KEY')
})

test('direct selection enables AnySearch without opening a key dialog', async () => {
const { text, mounted } = await runSearch('anysearch')

expect(mounted).toBeUndefined()
expect(text).toBe('Web search set to AnySearch.')
expect(savedEnvBlock).toEqual({ WEB_SEARCH_PROVIDER: 'anysearch' })
})

test('typing a key saves the backend and key without ever showing the key', async () => {
const { mounted } = await runSearch('tavily')
await mounted!.waitFor(f => f.includes('Tavily API key'))
Expand All @@ -206,6 +217,38 @@ describe('/search command', () => {
expect(process.env.TAVILY_API_KEY).toBe('tvly-secret-123')
})

test('an AnySearch key can be saved through hidden input and removed', async () => {
await runSearch('anysearch')
const { mounted } = await runSearch('key anysearch')
await mounted!.waitFor(f => f.includes('AnySearch API key'))

mounted!.stdin.write('private-any-key')
const masked = await mounted!.waitFor(f => f.includes('***************'))
mounted!.stdin.write('\r')
const message = await waitUntil(mounted!.done, text => text !== undefined)
mounted!.unmount()

expect(masked).not.toContain('private-any-key')
expect(message).toBe('Saved ANYSEARCH_API_KEY. AnySearch now uses your key.')
expect(savedEnvBlock).toEqual({
WEB_SEARCH_PROVIDER: 'anysearch',
ANYSEARCH_API_KEY: 'private-any-key',
})

const removed = await runSearch('remove-key anysearch')
expect(removed.text).toBe('Removed the saved ANYSEARCH_API_KEY.')
expect(process.env.ANYSEARCH_API_KEY).toBeUndefined()

let authorizationPresent = true
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
authorizationPresent = Object.hasOwn(init?.headers ?? {}, 'Authorization')
return new Response(JSON.stringify({ code: 0, data: { results: [] } }), { status: 200 })
}) as typeof fetch
const anonymousTest = await runSearch('test anonymous after removing key')
expect(anonymousTest.text).toContain('Search OK via AnySearch: 0 results')
expect(authorizationPresent).toBe(false)
})

test('Esc in the key dialog cancels without saving', async () => {
const { mounted } = await runSearch('key exa')
await mounted!.waitFor(f => f.includes('Exa API key'))
Expand Down
43 changes: 43 additions & 0 deletions src/commands/search/searchSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const SEARCH_ENV_KEYS = [
'EXA_API_KEY',
'EXA_FREE_TIER',
'TAVILY_API_KEY',
'ANYSEARCH_API_KEY',
'ANYSEARCH_MAX_RESULTS',
'FIRECRAWL_API_KEY',
'FIRECRAWL_API_URL',
'YOU_API_KEY',
Expand All @@ -50,6 +52,7 @@ const SEARCH_ENV_KEYS = [
] as const

const savedEnv = Object.fromEntries(SEARCH_ENV_KEYS.map(k => [k, process.env[k]]))
const originalFetch = globalThis.fetch

beforeEach(async () => {
await acquireSharedMutationLock('commands/search/searchSettings.test.ts')
Expand All @@ -58,6 +61,7 @@ beforeEach(async () => {

afterEach(() => {
try {
globalThis.fetch = originalFetch
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
Expand Down Expand Up @@ -124,6 +128,11 @@ describe('parseSearchArgs', () => {
})
expect(parseSearchArgs('duckduckgo')).toMatchObject({ kind: 'set', option: { mode: 'ddg' } })
expect(parseSearchArgs('builtin')).toMatchObject({ kind: 'set', option: { mode: 'native' } })
expect(parseSearchArgs('anysearch')).toMatchObject({
kind: 'set',
option: { mode: 'anysearch' },
typedInline: false,
})
})

test('extra tokens after a backend are flagged as an inline key', () => {
Expand Down Expand Up @@ -194,6 +203,7 @@ describe('needsApiKey', () => {
expect(needsApiKey(backend('exa'))).toBe(false)
expect(needsApiKey(backend('ddg'))).toBe(false)
expect(needsApiKey(backend('auto'))).toBe(false)
expect(needsApiKey(backend('anysearch'))).toBe(false)
})

test('Exa needs a key when the free tier is turned off', () => {
Expand Down Expand Up @@ -235,13 +245,28 @@ describe('describeSearchStatus', () => {
)
})

test('an explicit AnySearch selection reports the active backend without a key', () => {
process.env.WEB_SEARCH_PROVIDER = 'anysearch'
expect(describeSearchStatus(false)).toContain('Searches go to: AnySearch')
})

test('native mode without built-in search reports searches as unavailable', () => {
process.env.WEB_SEARCH_PROVIDER = 'native'
expect(describeSearchStatus(false)).toContain('searches are unavailable')
})
})

describe('applySearchSelection', () => {
test('saves AnySearch selection without requiring an API key', () => {
const store = recordingStore()
const message = applySearchSelection(backend('anysearch'), undefined, false, store)

expect(store.saves).toEqual([
{ set: { WEB_SEARCH_PROVIDER: 'anysearch' }, unset: [] },
])
expect(message).toBe('Web search set to AnySearch.')
})

test('saves the backend and its key, and applies them to this session', () => {
const store = recordingStore()
const message = applySearchSelection(backend('tavily'), ' tvly-key ', false, store)
Expand Down Expand Up @@ -456,6 +481,24 @@ describe('runSearchTest', () => {
durationSeconds: 1.234,
}

test('uses the selected AnySearch backend through the normal test entry', async () => {
process.env.WEB_SEARCH_PROVIDER = 'anysearch'
let requestedUrl = ''
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
requestedUrl = String(input)
return new Response(JSON.stringify({
code: 0,
data: { results: [{ title: 'Any result', url: 'https://example.com/result' }] },
}), { status: 200 })
}) as typeof fetch

const message = await runSearchTest('any query', false)

expect(requestedUrl).toBe('https://api.anysearch.com/v1/search')
expect(message).toContain('Search OK via AnySearch: 1 results')
expect(message).toContain('Top result: Any result — https://example.com/result')
})

test('reports the backend, count, timing, and top result', async () => {
let query = ''
const message = await runSearchTest('node release', false, async input => {
Expand Down
10 changes: 9 additions & 1 deletion src/commands/search/searchSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const SEARCH_BACKENDS: readonly SearchBackendOption[] = [
{
mode: 'auto',
label: 'Auto (recommended)',
description: 'Exa first, then any backend you configured, then free fallbacks',
description: 'Exa first, then configured auto backends, then free fallbacks',
},
{
mode: 'exa',
Expand All @@ -54,6 +54,13 @@ export const SEARCH_BACKENDS: readonly SearchBackendOption[] = [
keyOptional: true,
description: 'Exa only — free tier without a key, higher limits with one',
},
{
mode: 'anysearch',
label: 'AnySearch',
keyEnv: 'ANYSEARCH_API_KEY',
keyOptional: true,
description: 'AnySearch only — anonymous access without a key, higher limits with one',
},
{ mode: 'tavily', label: 'Tavily', keyEnv: 'TAVILY_API_KEY', description: 'Tavily only' },
{
mode: 'brave',
Expand Down Expand Up @@ -95,6 +102,7 @@ const BACKEND_ALIASES: Record<string, ProviderMode> = {
const BACKEND_DISPLAY_NAMES: Record<string, string> = {
exa: 'Exa (API key)',
'exa-free': 'Exa free tier',
anysearch: 'AnySearch',
ollama: 'Ollama',
firecrawl: 'Firecrawl',
tavily: 'Tavily',
Expand Down
14 changes: 14 additions & 0 deletions src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ instead.
| Tavily | `TAVILY_API_KEY` | `Authorization: Bearer` | POST |
| Exa (default) | `EXA_API_KEY` | `x-api-key` | POST |
| Exa free tier | *(none — zero-config default)* | — | POST |
| AnySearch (explicit only) | `ANYSEARCH_API_KEY` (optional) | `Authorization: Bearer` when set | POST |
| You.com | `YOU_API_KEY` | `X-API-Key` | GET |
| Jina | `JINA_API_KEY` | `Authorization: Bearer` | GET |
| Bing | `BING_API_KEY` | `Ocp-Apim-Subscription-Key` | GET |
Expand Down Expand Up @@ -83,6 +84,7 @@ export WEB_SEARCH_API=https://search.example.com/search
| `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 — keyed API when `EXA_API_KEY` is set, otherwise the free tier; throws on failure |
| `anysearch` | AnySearch only — anonymous without a key, Bearer authentication with one; throws on failure |
| `brave` | Brave only — throws on failure |
| `custom` | Custom API only — throws on failure. **Not in the auto chain** — must be explicitly selected |
| `firecrawl` | Firecrawl only — throws on failure |
Expand All @@ -101,6 +103,8 @@ there too.

> **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.

AnySearch is also explicit-only: selecting it does not change the existing `auto` chain.

```bash
# Fail loudly if Tavily is down (don't silently switch backends)
export WEB_SEARCH_PROVIDER=tavily
Expand All @@ -121,6 +125,16 @@ Invalid, fractional, zero, negative, or very large values fall back to 15s. Cust

## Provider Request & Response Formats

### AnySearch

Run `/search anysearch` to use its general web search without a key, or `/search key anysearch` to add an optional key through hidden input. Anonymous access has lower service limits. `/search remove-key anysearch` removes a key saved by `/search`; keys from the shell or `settings.json` remain controlled by those sources.

`ANYSEARCH_MAX_RESULTS` accepts an integer from 1 to 10 (default 10). Invalid values use 10. General search sends `query` and `max_results` to `POST https://api.anysearch.com/v1/search`. Results come from `data.results`; OpenClaude reports HTTP and business-code failures separately from an empty result list. A key, when present, is sent only as `Authorization: Bearer <key>` to that endpoint.

```json
{"query":"search terms","max_results":10}
```

### Ollama

When the active route resolves to Ollama, OpenClaude first calls the configured server's signed-in proxy:
Expand Down
Loading
Loading