mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(web-search): support Exa baseUrl
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
<ParamField path="query" type="string" required>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user