mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(web-search): support Exa baseUrl
This commit is contained in:
@@ -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