fix(coding-agent): restored exa mcp fallback
Restored Exa's unauthenticated MCP fallback when neither auth storage nor EXA_API_KEY provides credentials. Preserved API-key search ordering and bounded the MCP request with the web-search hard timeout. Updated provider copy and regression coverage for the no-key path.\n\nFixes #1860
This commit is contained in:
@@ -149,8 +149,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: 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.
|
||||
- Availability: settings must not explicitly disable `exa.enabled` or `exa.enableSearch`; Exa can use public MCP when no credential exists.
|
||||
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`.
|
||||
- `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`
|
||||
@@ -225,4 +225,4 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- The prompt says `recency` is for Brave and Perplexity, but code also implements it for Tavily and SearXNG.
|
||||
- The year rewrite in `executeSearch()` is blunt: any `2020`-`2029` substring is replaced with the current year.
|
||||
- `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain.
|
||||
- Exa requires an API key from the environment or credential store; it no longer falls back to unauthenticated MCP search.
|
||||
- Exa uses `authStorage.getApiKey("exa")`, then `EXA_API_KEY`, then unauthenticated `https://mcp.exa.ai/mcp` fallback.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Exa web search with no stored or environment credential to use the public Exa MCP fallback again, preserving the auth storage → `EXA_API_KEY` → `mcp.exa.ai` resolution order ([#1860](https://github.com/can1357/oh-my-pi/issues/1860)).
|
||||
- Fixed `provider.appendOnlyContext: "auto"` staying inactive for Xiaomi Token Plan/SGLang endpoints, preserving prefix-cache hits without forcing append-only mode globally ([#1851](https://github.com/can1357/oh-my-pi/issues/1851)).
|
||||
- Fixed `models.yml` compatibility parsing to preserve `compat.cacheControlFormat: "anthropic"` for custom OpenAI-compatible Claude proxies. ([#1845](https://github.com/can1357/oh-my-pi/issues/1845))
|
||||
- Fixed the TUI's `Settings → Plugins` panel reporting "No plugins installed" when only marketplace plugins were installed. The panel now merges `PluginManager.list()` with `MarketplaceManager.listInstalledPlugins()` — the same data source the `/plugins list` slash command and `omp plugin list` CLI already used — and tags each row with an `[npm]` / `[marketplace]` kind badge, a scope tag, and a shadow indicator for project-shadowed user installs. Selecting a marketplace row opens a new `MarketplacePluginDetailComponent` whose single `Enabled` toggle calls `MarketplaceManager.setPluginEnabled(pluginId, enabled, scope)`, with read-only metadata (version, install path, installed-at, last-updated, git commit SHA) listed below the toggle. The empty-state now lists both install commands (`omp plugin install <package>` and `omp plugin install <name>@<marketplace>`) ([#1842](https://github.com/can1357/oh-my-pi/issues/1842)).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TSchema } from "@oh-my-pi/pi-ai";
|
||||
import { $env, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types";
|
||||
import { callMCP } from "../mcp/json-rpc";
|
||||
import { type CallMcpOptions, callMCP } from "../mcp/json-rpc";
|
||||
import type {
|
||||
ExaRenderDetails,
|
||||
ExaSearchResponse,
|
||||
@@ -105,15 +105,21 @@ export async function callExaTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
apiKey: string | null,
|
||||
options?: CallMcpOptions,
|
||||
): Promise<unknown> {
|
||||
const params = new URLSearchParams();
|
||||
if (apiKey) params.set("exaApiKey", apiKey);
|
||||
params.set("tools", toolName);
|
||||
const url = `https://mcp.exa.ai/mcp?${params.toString()}`;
|
||||
const response = (await callMCP(url, "tools/call", {
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
})) as MCPCallResponse;
|
||||
const response = (await callMCP(
|
||||
url,
|
||||
"tools/call",
|
||||
{
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
},
|
||||
options,
|
||||
)) as MCPCallResponse;
|
||||
|
||||
if (response.error) {
|
||||
logger.error("MCP tools/call error", { toolName, args, error: response.error });
|
||||
|
||||
@@ -37,18 +37,25 @@ export interface JsonRpcResponse<T = unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Options controlling a single MCP JSON-RPC HTTP request. */
|
||||
export interface CallMcpOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP server with JSON-RPC 2.0 over HTTPS.
|
||||
*
|
||||
* @param url - Full MCP server URL (including any query parameters)
|
||||
* @param method - JSON-RPC method name (e.g., "tools/list", "tools/call")
|
||||
* @param params - Method parameters
|
||||
* @param options - Optional transport controls such as cancellation.
|
||||
* @returns Parsed JSON-RPC response
|
||||
*/
|
||||
export async function callMCP<T = unknown>(
|
||||
url: string,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: CallMcpOptions,
|
||||
): Promise<JsonRpcResponse<T>> {
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
@@ -64,6 +71,7 @@ export async function callMCP<T = unknown>(
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { settings } from "../../../config/settings";
|
||||
import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
|
||||
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
@@ -56,6 +57,71 @@ interface ExaSearchResponse {
|
||||
costDollars?: { total: number };
|
||||
searchTime?: number;
|
||||
}
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseOptionalField(section: string, label: string): string | null | undefined {
|
||||
const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
|
||||
const match = section.match(regex);
|
||||
if (!match) return undefined;
|
||||
const value = match[1].trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseTextField(section: string): string | null | undefined {
|
||||
const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/);
|
||||
if (!match) return undefined;
|
||||
const value = match[1].trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null {
|
||||
const root = asRecord(payload);
|
||||
if (!root) return null;
|
||||
|
||||
const content = root.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
|
||||
const textBlocks = content
|
||||
.map(item => {
|
||||
const part = asRecord(item);
|
||||
const text = typeof part?.text === "string" ? part.text : "";
|
||||
return text.replace(/\r\n?/g, "\n").trim();
|
||||
})
|
||||
.filter(text => text.length > 0);
|
||||
|
||||
if (textBlocks.length === 0) return null;
|
||||
|
||||
const sections = textBlocks
|
||||
.join("\n\n")
|
||||
.split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/)
|
||||
.map(section => section.trim())
|
||||
.filter(section => section.startsWith("Title:"));
|
||||
|
||||
const results: ExaSearchResult[] = [];
|
||||
for (const section of sections) {
|
||||
const title = parseOptionalField(section, "Title");
|
||||
const url = parseOptionalField(section, "URL");
|
||||
const author = parseOptionalField(section, "Author");
|
||||
const publishedDate = parseOptionalField(section, "Published Date");
|
||||
const text = parseTextField(section);
|
||||
|
||||
if (!title && !url && !text) continue;
|
||||
|
||||
results.push({
|
||||
title: title ?? undefined,
|
||||
url: url ?? undefined,
|
||||
author: author ?? undefined,
|
||||
publishedDate: publishedDate ?? undefined,
|
||||
text: text ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) return null;
|
||||
return { results };
|
||||
}
|
||||
|
||||
export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
|
||||
if (!type) return "auto";
|
||||
@@ -133,6 +199,32 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
||||
|
||||
return response.json() as Promise<ExaSearchResponse>;
|
||||
}
|
||||
function buildExaMcpArgs(params: ExaSearchParams): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = { query: params.query };
|
||||
if (params.num_results !== undefined) args.num_results = params.num_results;
|
||||
if (params.type !== undefined) args.type = params.type;
|
||||
if (params.include_domains !== undefined) args.include_domains = params.include_domains;
|
||||
if (params.exclude_domains !== undefined) args.exclude_domains = params.exclude_domains;
|
||||
if (params.start_published_date !== undefined) args.start_published_date = params.start_published_date;
|
||||
if (params.end_published_date !== undefined) args.end_published_date = params.end_published_date;
|
||||
return args;
|
||||
}
|
||||
|
||||
async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
|
||||
const response = await callExaTool("web_search_exa", buildExaMcpArgs(params), findApiKey(), {
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
if (isSearchResponse(response)) {
|
||||
return response as ExaSearchResponse;
|
||||
}
|
||||
|
||||
const parsed = parseExaMcpTextPayload(response);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new Error("Exa MCP search returned unexpected response shape.");
|
||||
}
|
||||
|
||||
/** Execute Exa web search */
|
||||
export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
|
||||
@@ -140,11 +232,7 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
|
||||
? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
|
||||
: undefined;
|
||||
const apiKey = storedKey ?? getEnvApiKey("exa");
|
||||
if (!apiKey) {
|
||||
throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.");
|
||||
}
|
||||
|
||||
const response = await callExaSearch(apiKey, params);
|
||||
const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
|
||||
|
||||
// Convert to unified SearchResponse
|
||||
const sources: SearchSource[] = [];
|
||||
@@ -182,15 +270,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; credential availability is still authoritative.
|
||||
// Settings may be unavailable before CLI initialization; public MCP fallback remains available.
|
||||
}
|
||||
return authStorage.hasAuth("exa");
|
||||
return true;
|
||||
}
|
||||
|
||||
search(params: SearchParams): Promise<SearchResponse> {
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SEARCH_PROVIDER_OPTIONS = [
|
||||
description: "OpenAI's native web_search (uses ChatGPT OAuth via /login openai-codex)",
|
||||
},
|
||||
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
|
||||
{ value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
|
||||
{ value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
|
||||
{ value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
|
||||
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
|
||||
{ value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
|
||||
|
||||
@@ -366,20 +366,67 @@ describe("searchExa", () => {
|
||||
expect(result.answer).toContain("**Has URL**: real summary");
|
||||
});
|
||||
|
||||
it("requires Exa credentials before starting a search", async () => {
|
||||
it("uses Exa MCP when API key is missing", async () => {
|
||||
delete process.env.EXA_API_KEY;
|
||||
const fetchSpy = vi.fn(async () => {
|
||||
return new Response(JSON.stringify(makeMockExaResponse()), {
|
||||
let calledUrl = "";
|
||||
using _hook = hookFetch((url, init) => {
|
||||
calledUrl = String(url);
|
||||
if (init?.body) {
|
||||
capturedRequestBody = JSON.parse(init.body as string);
|
||||
}
|
||||
return new Response(JSON.stringify({ jsonrpc: "2.0", id: "mcp-1", result: makeMockExaResponse() }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
using _hook = hookFetch(fetchSpy);
|
||||
|
||||
await expect(searchExa({ query: "no key" })).rejects.toThrow(
|
||||
"Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.",
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
const result = await searchExa({ query: "no key" });
|
||||
|
||||
expect(result.provider).toBe("exa");
|
||||
expect(result.sources).toHaveLength(3);
|
||||
expect(calledUrl).toContain("https://mcp.exa.ai/mcp");
|
||||
expect(calledUrl).toContain("tools=web_search_exa");
|
||||
expect(calledUrl).not.toContain("exaApiKey=");
|
||||
expect(capturedRequestBody?.method).toBe("tools/call");
|
||||
expect(capturedRequestBody?.params).toEqual({
|
||||
name: "web_search_exa",
|
||||
arguments: { query: "no key" },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses Exa MCP plain-text payloads when API key is missing", async () => {
|
||||
delete process.env.EXA_API_KEY;
|
||||
using _hook = hookFetch(() => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "mcp-text",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Title: Plain Result\nURL: https://plain.example\nAuthor: Reporter\nPublished Date: 2024-06-01\nText: Plain text body",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
const result = await searchExa({ query: "plain text" });
|
||||
|
||||
expect(result.provider).toBe("exa");
|
||||
expect(result.sources).toEqual([
|
||||
{
|
||||
title: "Plain Result",
|
||||
url: "https://plain.example",
|
||||
snippet: "Plain text body",
|
||||
publishedDate: "2024-06-01",
|
||||
ageSeconds: expect.any(Number),
|
||||
author: "Reporter",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses AuthStorage credentials when EXA_API_KEY is unset", async () => {
|
||||
@@ -402,12 +449,12 @@ describe("searchExa", () => {
|
||||
expect(receivedKey).toBe("stored-key-xyz");
|
||||
});
|
||||
|
||||
it("reports unavailable without EXA_API_KEY or stored credentials", async () => {
|
||||
it("reports available 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)),
|
||||
);
|
||||
expect(available).toBe(false);
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it("reports available with EXA_API_KEY", async () => {
|
||||
|
||||
Reference in New Issue
Block a user