fix(coding-agent): resolve exa credentials through auth storage

ExaProvider.isAvailable() and searchExa() now consult AuthStorage so Exa credentials configured through the broker/credential store work alongside EXA_API_KEY, matching the other API-key search providers.

Refs #1695
This commit is contained in:
roboomp
2026-06-02 08:00:35 +00:00
parent 963bdebf53
commit b3bcf966ee
4 changed files with 51 additions and 11 deletions
+2 -2
View File
@@ -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`
+1 -1
View File
@@ -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 <db.sqlite>` 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.
@@ -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<E
/** Execute Exa web search */
export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
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<SearchResponse> {
@@ -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,
});
}
}
@@ -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<string, string> | 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");