From d03e156028735db0e1d0bc131666e7484c35b974 Mon Sep 17 00:00:00 2001 From: RTrippler Date: Tue, 27 Jan 2026 19:57:31 +0100 Subject: [PATCH] feat(web_search): add optional model parameter for Perplexity provider Allows runtime model override without config changes or gateway restart. - Add 'model' parameter to web_search tool schema - Override configured default when parameter is provided - Return error if used with non-Perplexity provider (Brave) Closes #2882 --- src/agents/tools/web-search.test.ts | 94 +++++++++++++++++++++++- src/agents/tools/web-search.ts | 106 +++++++++++++++++++++++++--- 2 files changed, 189 insertions(+), 11 deletions(-) diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index c6d2b6405..b55c6fbec 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./web-search.js"; -const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = - __testing; +const { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + normalizeFreshness, + resolvePerplexityRequestParams, + buildWebSearchCacheKey, +} = __testing; describe("web_search perplexity baseUrl defaults", () => { it("detects a Perplexity key prefix", () => { @@ -69,3 +74,88 @@ describe("web_search freshness normalization", () => { expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined(); }); }); + +describe("web_search model parameter", () => { + it("prefixes short model names for OpenRouter", () => { + const { effectivePerplexityModel: shortModel } = resolvePerplexityRequestParams({ + baseUrl: "https://openrouter.ai/api/v1", + model: "sonar", + }); + expect(shortModel).toBe("perplexity/sonar"); + + const { effectivePerplexityModel: prefixedModel } = resolvePerplexityRequestParams({ + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + }); + expect(prefixedModel).toBe("perplexity/sonar-pro"); + }); + + it("keeps short model names for direct Perplexity", () => { + const { effectivePerplexityModel } = resolvePerplexityRequestParams({ + baseUrl: "https://api.perplexity.ai", + model: "sonar", + }); + expect(effectivePerplexityModel).toBe("sonar"); + }); + + it("differentiates cache keys by model", () => { + const baseParams = { + provider: "perplexity" as const, + query: "model test", + count: 5, + perplexityBaseUrl: "default", + }; + + const firstParams = resolvePerplexityRequestParams({ + baseUrl: "https://openrouter.ai/api/v1", + model: "sonar", + }); + const secondParams = resolvePerplexityRequestParams({ + baseUrl: "https://openrouter.ai/api/v1", + model: "sonar-pro", + }); + + const first = buildWebSearchCacheKey({ + ...baseParams, + perplexityModel: firstParams.cachePerplexityModel, + perplexityBaseUrl: firstParams.cachePerplexityBaseUrl, + }); + const second = buildWebSearchCacheKey({ + ...baseParams, + perplexityModel: secondParams.cachePerplexityModel, + perplexityBaseUrl: secondParams.cachePerplexityBaseUrl, + }); + + expect(first).not.toBe(second); + }); + + it("differentiates cache keys by baseUrl", () => { + const baseParams = { + provider: "perplexity" as const, + query: "base url test", + count: 5, + }; + + const openRouterParams = resolvePerplexityRequestParams({ + baseUrl: "https://openrouter.ai/api/v1", + model: "sonar", + }); + const directParams = resolvePerplexityRequestParams({ + baseUrl: "https://api.perplexity.ai", + model: "sonar", + }); + + const openRouter = buildWebSearchCacheKey({ + ...baseParams, + perplexityModel: openRouterParams.cachePerplexityModel, + perplexityBaseUrl: openRouterParams.cachePerplexityBaseUrl, + }); + const direct = buildWebSearchCacheKey({ + ...baseParams, + perplexityModel: directParams.cachePerplexityModel, + perplexityBaseUrl: directParams.cachePerplexityBaseUrl, + }); + + expect(openRouter).not.toBe(direct); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1d87676e8..9e642c75b 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -63,6 +63,12 @@ const WebSearchSchema = Type.Object({ "Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.", }), ), + model: Type.Optional( + Type.String({ + description: + "Perplexity model to use (Perplexity provider only). Overrides the configured default. Examples: 'sonar', 'sonar-pro', 'sonar-reasoning'.", + }), + ), }); type WebSearchConfig = NonNullable["web"] extends infer Web @@ -265,6 +271,62 @@ function resolveSiteName(url: string | undefined): string | undefined { } } +function resolvePerplexityRequestParams(params: { baseUrl?: string; model?: string }): { + effectivePerplexityBaseUrl: string; + effectivePerplexityModel: string; + cachePerplexityBaseUrl: string; + cachePerplexityModel: string; +} { + const normalizedPerplexityModel = typeof params.model === "string" ? params.model.trim() : ""; + + const normalizedPerplexityBaseUrlRaw = + typeof params.baseUrl === "string" ? params.baseUrl.trim() : ""; + const normalizedPerplexityBaseUrl = normalizedPerplexityBaseUrlRaw.replace(/\/+$/, ""); + const effectivePerplexityBaseUrl = normalizedPerplexityBaseUrl || DEFAULT_PERPLEXITY_BASE_URL; + const cachePerplexityBaseUrl = + !normalizedPerplexityBaseUrl || normalizedPerplexityBaseUrl === DEFAULT_PERPLEXITY_BASE_URL + ? "default" + : normalizedPerplexityBaseUrl; + const isOpenRouterBaseUrl = effectivePerplexityBaseUrl === DEFAULT_PERPLEXITY_BASE_URL; + const resolvedPerplexityModel = normalizedPerplexityModel || DEFAULT_PERPLEXITY_MODEL; + const effectivePerplexityModel = + isOpenRouterBaseUrl && + resolvedPerplexityModel && + !resolvedPerplexityModel.startsWith("perplexity/") + ? `perplexity/${resolvedPerplexityModel}` + : resolvedPerplexityModel; + const cachePerplexityModel = + !normalizedPerplexityModel || effectivePerplexityModel === DEFAULT_PERPLEXITY_MODEL + ? "default" + : effectivePerplexityModel; + + return { + effectivePerplexityBaseUrl, + effectivePerplexityModel, + cachePerplexityBaseUrl, + cachePerplexityModel, + }; +} + +function buildWebSearchCacheKey(params: { + provider: (typeof SEARCH_PROVIDERS)[number]; + query: string; + count: number; + country?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + perplexityBaseUrl?: string; + perplexityModel?: string; +}): string { + const base = `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`; + const suffix = + params.provider === "brave" + ? `${params.freshness || "default"}` + : `${params.perplexityModel || "default"}:${params.perplexityBaseUrl || "default"}`; + return normalizeCacheKey(`${base}:${suffix}`); +} + async function runPerplexitySearch(params: { query: string; apiKey: string; @@ -320,11 +382,27 @@ async function runWebSearch(params: { perplexityBaseUrl?: string; perplexityModel?: string; }): Promise> { - const cacheKey = normalizeCacheKey( - params.provider === "brave" - ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` - : `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`, - ); + const { + effectivePerplexityBaseUrl, + effectivePerplexityModel, + cachePerplexityBaseUrl, + cachePerplexityModel, + } = resolvePerplexityRequestParams({ + baseUrl: params.perplexityBaseUrl, + model: params.perplexityModel, + }); + + const cacheKey = buildWebSearchCacheKey({ + provider: params.provider, + query: params.query, + count: params.count, + country: params.country, + search_lang: params.search_lang, + ui_lang: params.ui_lang, + freshness: params.freshness, + perplexityBaseUrl: cachePerplexityBaseUrl, + perplexityModel: cachePerplexityModel, + }); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) return { ...cached.value, cached: true }; @@ -334,15 +412,15 @@ async function runWebSearch(params: { const { content, citations } = await runPerplexitySearch({ query: params.query, apiKey: params.apiKey, - baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + baseUrl: effectivePerplexityBaseUrl, + model: effectivePerplexityModel, timeoutSeconds: params.timeoutSeconds, }); const payload = { query: params.query, provider: params.provider, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + model: effectivePerplexityModel, tookMs: Date.now() - start, content, citations, @@ -450,6 +528,14 @@ export function createWebSearchTool(options?: { docs: "https://docs.molt.bot/tools/web", }); } + const modelOverride = readStringParam(params, "model"); + if (modelOverride && provider !== "perplexity") { + return jsonResult({ + error: "unsupported_model_override", + message: "model parameter is only supported by the Perplexity web_search provider.", + docs: "https://docs.molt.bot/tools/web", + }); + } const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined; if (rawFreshness && !freshness) { return jsonResult({ @@ -475,7 +561,7 @@ export function createWebSearchTool(options?: { perplexityAuth?.source, perplexityAuth?.apiKey, ), - perplexityModel: resolvePerplexityModel(perplexityConfig), + perplexityModel: modelOverride || resolvePerplexityModel(perplexityConfig), }); return jsonResult(result); }, @@ -486,4 +572,6 @@ export const __testing = { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness, + resolvePerplexityRequestParams, + buildWebSearchCacheKey, } as const;