fix(web-search): support Exa baseUrl

This commit is contained in:
Peter Steinberger
2026-05-02 06:06:33 +01:00
parent 8819f258cc
commit 12342ed0e8
7 changed files with 137 additions and 14 deletions

View File

@@ -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"
}
}
}

View File

@@ -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;

View File

@@ -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({