From 512b2053c50275b421594667e5e18b396c7e25a8 Mon Sep 17 00:00:00 2001 From: Chase Dorsey Date: Mon, 9 Feb 2026 13:43:57 -0500 Subject: [PATCH] fix(web_search): Fix invalid model name sent to Perplexity (#12795) * fix(web_search): Fix invalid model name sent to Perplexity * chore: Only apply fix to direct Perplexity calls * fix(web_search): normalize direct Perplexity model IDs * fix: add changelog note for perplexity model normalization (#12795) (thanks @cdorsey) * fix: align tests and fetch type for gate stability (#12795) (thanks @cdorsey) * chore: keep #12795 scoped to web_search changes --------- Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/tools/web-search.test.ts | 28 +++++++++++++++++++ src/agents/tools/web-search.ts | 27 ++++++++++++++++-- .../tools/web-tools.enabled-defaults.test.ts | 12 ++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9497119c5..194b5a34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. +- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey. - Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov. - Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking. - Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman. diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 4ba18598d..e4ae31326 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -4,6 +4,8 @@ import { __testing } from "./web-search.js"; const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, normalizeFreshness, resolveGrokApiKey, resolveGrokModel, @@ -58,6 +60,32 @@ describe("web_search perplexity baseUrl defaults", () => { }); }); +describe("web_search perplexity model normalization", () => { + it("detects direct Perplexity host", () => { + expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true); + expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai/")).toBe(true); + expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false); + }); + + it("strips provider prefix for direct Perplexity", () => { + expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe( + "sonar-pro", + ); + }); + + it("keeps prefixed model for OpenRouter", () => { + expect( + resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"), + ).toBe("perplexity/sonar-pro"); + }); + + it("keeps model unchanged when URL is invalid", () => { + expect(resolvePerplexityRequestModel("not-a-url", "perplexity/sonar-pro")).toBe( + "perplexity/sonar-pro", + ); + }); +}); + describe("web_search freshness normalization", () => { it("accepts Brave shortcut values", () => { expect(normalizeFreshness("pd")).toBe("pd"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 556d2d41c..f303c2a2d 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -280,6 +280,25 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string { return fromConfig || DEFAULT_PERPLEXITY_MODEL; } +function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { if (!search || typeof search !== "object") { return {}; @@ -379,7 +398,9 @@ async function runPerplexitySearch(params: { model: string; timeoutSeconds: number; }): Promise<{ content: string; citations: string[] }> { - const endpoint = `${params.baseUrl.replace(/\/$/, "")}/chat/completions`; + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const model = resolvePerplexityRequestModel(baseUrl, params.model); const res = await fetch(endpoint, { method: "POST", @@ -390,7 +411,7 @@ async function runPerplexitySearch(params: { "X-Title": "OpenClaw Web Search", }, body: JSON.stringify({ - model: params.model, + model, messages: [ { role: "user", @@ -686,6 +707,8 @@ export function createWebSearchTool(options?: { export const __testing = { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, normalizeFreshness, resolveGrokApiKey, resolveGrokModel, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 50522d4a9..4272ffb13 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -151,6 +151,12 @@ describe("web_search perplexity baseUrl defaults", () => { expect(mockFetch).toHaveBeenCalled(); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions"); + const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined; + const requestBody = request?.body; + const body = JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as { + model?: string; + }; + expect(body.model).toBe("sonar-pro"); }); it("rejects freshness for Perplexity provider", async () => { @@ -194,6 +200,12 @@ describe("web_search perplexity baseUrl defaults", () => { expect(mockFetch).toHaveBeenCalled(); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); + const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined; + const requestBody = request?.body; + const body = JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as { + model?: string; + }; + expect(body.model).toBe("perplexity/sonar-pro"); }); it("prefers PERPLEXITY_API_KEY when both env keys are set", async () => {