diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index d8439c3b2..d633a0b6e 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -145,8 +145,8 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `limit` and `num_search_results` are collapsed together before dispatch. - Output may include parsed free-text `answer`, `sources`, `requestId`. - **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts` - - Availability: `EXA_API_KEY` must be configured and settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. - - Querying: POST `https://api.exa.ai/search` with `EXA_API_KEY`. + - Availability: env or `agent.db` credential for `exa`; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. + - Querying: POST `https://api.exa.ai/search` with the resolved Exa API key. - `limit` and `num_search_results` are collapsed together before dispatch. - Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`. - **Parallel** — `packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9f65ae766..f3591e212 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,7 +15,7 @@ ### Fixed -- Fixed Exa web search reporting available without `EXA_API_KEY`, which could route searches into the unauthenticated public MCP fallback and stall before trying the next provider ([#1695](https://github.com/can1357/oh-my-pi/issues/1695)). +- Fixed Exa web search reporting available without Exa credentials, which could route searches into the unauthenticated public MCP fallback and stall before trying the next provider. Availability and `searchExa()` now resolve through the standard `AuthStorage` cascade (`EXA_API_KEY` env or stored credential) ([#1695](https://github.com/can1357/oh-my-pi/issues/1695)). - Fixed opening exported local files from WSL by sending existing paths through `wslpath -w` and launching `wslview` directly when available, avoiding `xdg-open`'s broken file-handler path translation ([#950](https://github.com/can1357/oh-my-pi/pull/950) by [@rxreyn3](https://github.com/rxreyn3)). - Fixed `/move` (and cross-project resume) not re-scoping the live project settings to the destination directory. Changing a session's working directory now reloads the project settings layer in place (via `Settings.reloadForCwd`) so project-scoped configuration and path-scoped `enabledModels`/`disabledProviders` follow the move instead of remaining pinned to the launch directory. - Fixed `read ` freezing the TUI on large databases. Listing tables ran an unbounded `SELECT COUNT(*)` per table, and since `bun:sqlite` executes synchronously on the same JS thread that drives rendering and input, a multi-GB database's full-table scans blocked the UI for seconds. The listing now reads the planner's `sqlite_stat1` estimate for tables above a scan cap (shown as `~N rows`) and only counts exactly when a table is provably small, reading at most `cap + 1` rows (a capped table shows `N+ rows`). On an 8.4 GB stats database the listing dropped from multi-second full scans to ~2 ms. diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index aaf123daa..bb25d761a 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -31,6 +31,12 @@ export interface ExaSearchParams { start_published_date?: string; end_published_date?: string; signal?: AbortSignal; + /** + * Credential source. Resolved before falling back to `EXA_API_KEY` so + * Exa works when the key is stored via the broker/auth pipeline. + */ + authStorage?: AuthStorage; + sessionId?: string; } interface ExaSearchResult { @@ -130,9 +136,12 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise { - const apiKey = getEnvApiKey("exa"); + const storedKey = params.authStorage + ? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal }) + : undefined; + const apiKey = storedKey ?? getEnvApiKey("exa"); if (!apiKey) { - throw new Error("EXA_API_KEY not found. Set it in environment or .env file."); + throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'."); } const response = await callExaSearch(apiKey, params); @@ -173,15 +182,15 @@ export class ExaProvider extends SearchProvider { readonly id = "exa"; readonly label = "Exa"; - isAvailable(_authStorage: AuthStorage): boolean { + isAvailable(authStorage: AuthStorage): boolean { try { if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) { return false; } } catch { - // Settings may be unavailable before CLI initialization; API-key availability is still authoritative. + // Settings may be unavailable before CLI initialization; credential availability is still authoritative. } - return !!getEnvApiKey("exa"); + return authStorage.hasAuth("exa"); } search(params: SearchParams): Promise { @@ -189,6 +198,8 @@ export class ExaProvider extends SearchProvider { query: params.query, num_results: params.numSearchResults ?? params.limit, signal: params.signal, + authStorage: params.authStorage, + sessionId: params.sessionId, }); } } diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index 4e06091f3..1962db2f0 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -366,7 +366,7 @@ describe("searchExa", () => { expect(result.answer).toContain("**Has URL**: real summary"); }); - it("requires EXA_API_KEY before starting a search", async () => { + it("requires Exa credentials before starting a search", async () => { delete process.env.EXA_API_KEY; const fetchSpy = vi.fn(async () => { return new Response(JSON.stringify(makeMockExaResponse()), { @@ -377,12 +377,32 @@ describe("searchExa", () => { using _hook = hookFetch(fetchSpy); await expect(searchExa({ query: "no key" })).rejects.toThrow( - "EXA_API_KEY not found. Set it in environment or .env file.", + "Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.", ); expect(fetchSpy).not.toHaveBeenCalled(); }); - it("reports unavailable without EXA_API_KEY", async () => { + it("uses AuthStorage credentials when EXA_API_KEY is unset", async () => { + delete process.env.EXA_API_KEY; + let receivedKey: string | undefined; + using _hook = hookFetch((_url, init) => { + receivedKey = (init?.headers as Record | undefined)?.["x-api-key"]; + return new Response(JSON.stringify(makeMockExaResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await withLocalAuthStorage(async authStorage => { + authStorage.setRuntimeApiKey("exa", "stored-key-xyz"); + const result = await searchExa({ query: "from auth storage", authStorage }); + expect(result.provider).toBe("exa"); + expect(result.sources).toHaveLength(3); + }); + expect(receivedKey).toBe("stored-key-xyz"); + }); + + it("reports unavailable without EXA_API_KEY or stored credentials", async () => { delete process.env.EXA_API_KEY; const available = await withLocalAuthStorage(authStorage => Promise.resolve(new ExaProvider().isAvailable(authStorage)), @@ -398,6 +418,15 @@ describe("searchExa", () => { expect(available).toBe(true); }); + it("reports available when AuthStorage holds a credential", async () => { + delete process.env.EXA_API_KEY; + const available = await withLocalAuthStorage(authStorage => { + authStorage.setRuntimeApiKey("exa", "stored-key"); + return Promise.resolve(new ExaProvider().isAvailable(authStorage)); + }); + expect(available).toBe(true); + }); + it("throws SearchProviderError on non-ok HTTP response", async () => { using _hook = mockFetch("Forbidden", 403); await expect(searchExa({ query: "forbidden" })).rejects.toThrow("exa: 403 forbidden");