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:
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user