diff --git a/CHANGELOG.md b/CHANGELOG.md index de57c70db4f..c92123014bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk. - Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner. +- Web search/Exa: accept `plugins.entries.exa.config.webSearch.baseUrl`, normalize it to the Exa `/search` endpoint, and partition cached results by endpoint. Fixes #54928 and supersedes #54939. Thanks @mrpl327 and @lyfuci. - Web search/MiniMax: include MiniMax Search in the web-search setup flow and let `MINIMAX_API_KEY` participate in MiniMax Search auto-detection. Supersedes #65828. Thanks @Jah-yee. - Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana. - Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2e542edd228..c4d12df1dde 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ae25cb1d397f1ea9642047ef13d35300c807cb1cd67f681c0b5af83b572b3638 config-baseline.json -0a1907d595765b8bb7a41348d14323920ab50e402be49a19a45a4e2499306407 config-baseline.core.json -c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json -7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json +051884bad7339a302ecb75e5f61831b1726c6f0360de27485aac76097570c808 config-baseline.json +80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json +eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json +6bd6c72b17801072b2d3285c82f4c21adcc95f0edffc1e6f64e767d0a07b678f config-baseline.plugin.json diff --git a/docs/tools/exa-search.md b/docs/tools/exa-search.md index 917672484db..8a101cc5cf8 100644 --- a/docs/tools/exa-search.md +++ b/docs/tools/exa-search.md @@ -38,6 +38,7 @@ extraction (highlights, text, summaries). config: { webSearch: { apiKey: "exa-...", // optional if EXA_API_KEY is set + baseUrl: "https://api.exa.ai", // optional; OpenClaw appends /search }, }, }, @@ -56,6 +57,14 @@ extraction (highlights, text, summaries). **Environment alternative:** set `EXA_API_KEY` in the Gateway environment. For a gateway install, put it in `~/.openclaw/.env`. +## Base URL override + +Set `plugins.entries.exa.config.webSearch.baseUrl` when Exa search requests +should go through a compatible proxy or alternate Exa endpoint. OpenClaw +normalizes bare hosts by prepending `https://` and appends `/search` unless the +path already ends there. The resolved endpoint is included in the search cache +key, so results from different Exa endpoints are not shared. + ## Tool parameters diff --git a/docs/tools/web.md b/docs/tools/web.md index af9ddb300f2..b2761eb4bf1 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -170,7 +170,7 @@ API-backed providers first: 5. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey` (order 40) 6. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey` (order 50) 7. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60) -8. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey` (order 65) +8. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`; optional `plugins.entries.exa.config.webSearch.baseUrl` overrides the Exa endpoint (order 65) 9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70) Key-free fallbacks after that: diff --git a/extensions/exa/openclaw.plugin.json b/extensions/exa/openclaw.plugin.json index 8a2c0865634..9bfc5d63d35 100644 --- a/extensions/exa/openclaw.plugin.json +++ b/extensions/exa/openclaw.plugin.json @@ -12,6 +12,10 @@ "help": "Exa Search API key (fallback: EXA_API_KEY env var).", "sensitive": true, "placeholder": "exa-..." + }, + "webSearch.baseUrl": { + "label": "Exa Search Base URL", + "help": "Optional Exa Search API base URL override. OpenClaw appends /search when the URL does not already end there." } }, "contracts": { @@ -30,6 +34,9 @@ "properties": { "apiKey": { "type": ["string", "object"] + }, + "baseUrl": { + "type": "string" } } } diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts index 5f5dde0c5f9..a837641aad1 100644 --- a/extensions/exa/src/exa-web-search-provider.runtime.ts +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -29,6 +29,7 @@ const EXA_MAX_SEARCH_COUNT = 100; type ExaConfig = { apiKey?: string; + baseUrl?: string; }; type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; @@ -87,6 +88,44 @@ function resolveExaApiKey(exa?: ExaConfig): string | undefined { ); } +function invalidBaseUrlPayload(value: string) { + return { + error: "invalid_base_url", + message: `plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL. Got: ${value}`, + docs: "https://docs.openclaw.ai/tools/exa-search", + }; +} + +function resolveExaSearchEndpoint( + exa?: ExaConfig, +): { endpoint: string } | { error: string; message: string; docs: string } { + const configured = normalizeOptionalString(exa?.baseUrl); + if (!configured) { + return { endpoint: EXA_SEARCH_ENDPOINT }; + } + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) { + return invalidBaseUrlPayload(configured); + } + const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return invalidBaseUrlPayload(configured); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return invalidBaseUrlPayload(configured); + } + + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.pathname = pathname.endsWith("/search") + ? pathname + : `${pathname === "" ? "" : pathname}/search`; + parsed.hash = ""; + return { endpoint: parsed.toString() }; +} + function resolveExaDescription(result: ExaSearchResult): string { const highlights = result.highlights; if (Array.isArray(highlights)) { @@ -315,6 +354,7 @@ function resolveFreshnessStartDate(freshness: ExaFreshness): string { async function runExaSearch(params: { apiKey: string; + endpoint: string; query: string; count: number; freshness?: ExaFreshness; @@ -342,7 +382,7 @@ async function runExaSearch(params: { return withTrustedWebSearchEndpoint( { - url: EXA_SEARCH_ENDPOINT, + url: params.endpoint, timeoutSeconds: params.timeoutSeconds, init: { method: "POST", @@ -378,6 +418,31 @@ function missingExaKeyPayload() { }; } +function buildExaCacheKey(params: { + endpoint: string; + type: ExaSearchType; + query: string; + count: number; + freshness?: ExaFreshness; + dateAfter?: string; + dateBefore?: string; + contents?: ExaContentsArgs; +}): string { + return buildSearchCacheKey([ + "exa", + params.endpoint, + params.type, + params.query, + params.count, + params.freshness, + params.dateAfter, + params.dateBefore, + params.contents?.highlights ? JSON.stringify(params.contents.highlights) : undefined, + params.contents?.text ? JSON.stringify(params.contents.text) : undefined, + params.contents?.summary ? JSON.stringify(params.contents.summary) : undefined, + ]); +} + export async function executeExaWebSearchProviderTool( ctx: { config?: Record; searchConfig?: SearchConfigRecord }, args: Record, @@ -393,6 +458,11 @@ export async function executeExaWebSearchProviderTool( if (!apiKey) { return missingExaKeyPayload(); } + const endpointResult = resolveExaSearchEndpoint(exaConfig); + if ("error" in endpointResult) { + return endpointResult; + } + const endpoint = endpointResult.endpoint; const query = readStringParam(params, "query", { required: true }); const rawType = readStringParam(params, "type"); @@ -442,18 +512,17 @@ export async function executeExaWebSearchProviderTool( ? parsedContents.value : undefined; - const cacheKey = buildSearchCacheKey([ - "exa", + const resolvedCount = resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT); + const cacheKey = buildExaCacheKey({ + endpoint, type, query, - resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + count: resolvedCount, freshness, dateAfter, dateBefore, - contents?.highlights ? JSON.stringify(contents.highlights) : undefined, - contents?.text ? JSON.stringify(contents.text) : undefined, - contents?.summary ? JSON.stringify(contents.summary) : undefined, - ]); + contents, + }); const cached = readCachedSearchPayload(cacheKey); if (cached) { return cached; @@ -462,8 +531,9 @@ export async function executeExaWebSearchProviderTool( const start = Date.now(); const results = await runExaSearch({ apiKey, + endpoint, query, - count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + count: resolvedCount, freshness, dateAfter, dateBefore, @@ -519,9 +589,11 @@ export const __testing = { normalizeExaResults, normalizeExaFreshness, parseExaContents, + buildExaCacheKey, resolveExaApiKey, resolveExaConfig, resolveExaDescription, resolveExaSearchCount, + resolveExaSearchEndpoint, resolveFreshnessStartDate, } as const; diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 66511180d00..5fd9dae255d 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -46,6 +46,40 @@ describe("exa web search provider", () => { expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret"); }); + it("resolves Exa search base URL overrides", () => { + expect(__testing.resolveExaSearchEndpoint()).toEqual({ + endpoint: "https://api.exa.ai/search", + }); + expect(__testing.resolveExaSearchEndpoint({ baseUrl: "https://proxy.example/exa" })).toEqual({ + endpoint: "https://proxy.example/exa/search", + }); + expect(__testing.resolveExaSearchEndpoint({ baseUrl: "proxy.example/exa/search/" })).toEqual({ + endpoint: "https://proxy.example/exa/search", + }); + expect(__testing.resolveExaSearchEndpoint({ baseUrl: "ftp://proxy.example/exa" })).toEqual( + expect.objectContaining({ error: "invalid_base_url" }), + ); + }); + + it("partitions Exa cache keys by resolved endpoint", () => { + const base = { + type: "auto" as const, + query: "openclaw", + count: 5, + }; + expect( + __testing.buildExaCacheKey({ + ...base, + endpoint: "https://api.exa.ai/search", + }), + ).not.toBe( + __testing.buildExaCacheKey({ + ...base, + endpoint: "https://proxy.example/exa/search", + }), + ); + }); + it("normalizes Exa result descriptions from highlights before text", () => { expect( __testing.resolveExaDescription({