mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:00:42 +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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
051884bad7339a302ecb75e5f61831b1726c6f0360de27485aac76097570c808 config-baseline.json
|
||||||
0a1907d595765b8bb7a41348d14323920ab50e402be49a19a45a4e2499306407 config-baseline.core.json
|
80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json
|
||||||
c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json
|
eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json
|
||||||
7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json
|
6bd6c72b17801072b2d3285c82f4c21adcc95f0edffc1e6f64e767d0a07b678f config-baseline.plugin.json
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ extraction (highlights, text, summaries).
|
|||||||
config: {
|
config: {
|
||||||
webSearch: {
|
webSearch: {
|
||||||
apiKey: "exa-...", // optional if EXA_API_KEY is set
|
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.
|
**Environment alternative:** set `EXA_API_KEY` in the Gateway environment.
|
||||||
For a gateway install, put it in `~/.openclaw/.env`.
|
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
|
## Tool parameters
|
||||||
|
|
||||||
<ParamField path="query" type="string" required>
|
<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)
|
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)
|
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)
|
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)
|
9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70)
|
||||||
|
|
||||||
Key-free fallbacks after that:
|
Key-free fallbacks after that:
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
"help": "Exa Search API key (fallback: EXA_API_KEY env var).",
|
"help": "Exa Search API key (fallback: EXA_API_KEY env var).",
|
||||||
"sensitive": true,
|
"sensitive": true,
|
||||||
"placeholder": "exa-..."
|
"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": {
|
"contracts": {
|
||||||
@@ -30,6 +34,9 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": ["string", "object"]
|
"type": ["string", "object"]
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const EXA_MAX_SEARCH_COUNT = 100;
|
|||||||
|
|
||||||
type ExaConfig = {
|
type ExaConfig = {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
|
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 {
|
function resolveExaDescription(result: ExaSearchResult): string {
|
||||||
const highlights = result.highlights;
|
const highlights = result.highlights;
|
||||||
if (Array.isArray(highlights)) {
|
if (Array.isArray(highlights)) {
|
||||||
@@ -315,6 +354,7 @@ function resolveFreshnessStartDate(freshness: ExaFreshness): string {
|
|||||||
|
|
||||||
async function runExaSearch(params: {
|
async function runExaSearch(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
endpoint: string;
|
||||||
query: string;
|
query: string;
|
||||||
count: number;
|
count: number;
|
||||||
freshness?: ExaFreshness;
|
freshness?: ExaFreshness;
|
||||||
@@ -342,7 +382,7 @@ async function runExaSearch(params: {
|
|||||||
|
|
||||||
return withTrustedWebSearchEndpoint(
|
return withTrustedWebSearchEndpoint(
|
||||||
{
|
{
|
||||||
url: EXA_SEARCH_ENDPOINT,
|
url: params.endpoint,
|
||||||
timeoutSeconds: params.timeoutSeconds,
|
timeoutSeconds: params.timeoutSeconds,
|
||||||
init: {
|
init: {
|
||||||
method: "POST",
|
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(
|
export async function executeExaWebSearchProviderTool(
|
||||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
@@ -393,6 +458,11 @@ export async function executeExaWebSearchProviderTool(
|
|||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return missingExaKeyPayload();
|
return missingExaKeyPayload();
|
||||||
}
|
}
|
||||||
|
const endpointResult = resolveExaSearchEndpoint(exaConfig);
|
||||||
|
if ("error" in endpointResult) {
|
||||||
|
return endpointResult;
|
||||||
|
}
|
||||||
|
const endpoint = endpointResult.endpoint;
|
||||||
|
|
||||||
const query = readStringParam(params, "query", { required: true });
|
const query = readStringParam(params, "query", { required: true });
|
||||||
const rawType = readStringParam(params, "type");
|
const rawType = readStringParam(params, "type");
|
||||||
@@ -442,18 +512,17 @@ export async function executeExaWebSearchProviderTool(
|
|||||||
? parsedContents.value
|
? parsedContents.value
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const cacheKey = buildSearchCacheKey([
|
const resolvedCount = resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT);
|
||||||
"exa",
|
const cacheKey = buildExaCacheKey({
|
||||||
|
endpoint,
|
||||||
type,
|
type,
|
||||||
query,
|
query,
|
||||||
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
count: resolvedCount,
|
||||||
freshness,
|
freshness,
|
||||||
dateAfter,
|
dateAfter,
|
||||||
dateBefore,
|
dateBefore,
|
||||||
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
|
contents,
|
||||||
contents?.text ? JSON.stringify(contents.text) : undefined,
|
});
|
||||||
contents?.summary ? JSON.stringify(contents.summary) : undefined,
|
|
||||||
]);
|
|
||||||
const cached = readCachedSearchPayload(cacheKey);
|
const cached = readCachedSearchPayload(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
@@ -462,8 +531,9 @@ export async function executeExaWebSearchProviderTool(
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const results = await runExaSearch({
|
const results = await runExaSearch({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
endpoint,
|
||||||
query,
|
query,
|
||||||
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
count: resolvedCount,
|
||||||
freshness,
|
freshness,
|
||||||
dateAfter,
|
dateAfter,
|
||||||
dateBefore,
|
dateBefore,
|
||||||
@@ -519,9 +589,11 @@ export const __testing = {
|
|||||||
normalizeExaResults,
|
normalizeExaResults,
|
||||||
normalizeExaFreshness,
|
normalizeExaFreshness,
|
||||||
parseExaContents,
|
parseExaContents,
|
||||||
|
buildExaCacheKey,
|
||||||
resolveExaApiKey,
|
resolveExaApiKey,
|
||||||
resolveExaConfig,
|
resolveExaConfig,
|
||||||
resolveExaDescription,
|
resolveExaDescription,
|
||||||
resolveExaSearchCount,
|
resolveExaSearchCount,
|
||||||
|
resolveExaSearchEndpoint,
|
||||||
resolveFreshnessStartDate,
|
resolveFreshnessStartDate,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -46,6 +46,40 @@ describe("exa web search provider", () => {
|
|||||||
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
|
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", () => {
|
it("normalizes Exa result descriptions from highlights before text", () => {
|
||||||
expect(
|
expect(
|
||||||
__testing.resolveExaDescription({
|
__testing.resolveExaDescription({
|
||||||
|
|||||||
Reference in New Issue
Block a user