From 3e546e691d6f59573e996eef98681694376dfa37 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 07:27:25 +0000 Subject: [PATCH] fix: infer perplexity baseUrl from api key --- CHANGELOG.md | 5 ++ docs/perplexity.md | 7 ++- docs/tools/web.md | 13 ++-- src/agents/tools/web-search.test.ts | 59 +++++++++++++++++++ src/agents/tools/web-search.ts | 47 ++++++++++++--- .../tools/web-tools.enabled-defaults.test.ts | 31 +++++++++- 6 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 src/agents/tools/web-search.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7287e37..9b9f73fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Docs: https://docs.clawd.bot +## 2026.1.20-1 + +### Fixes +- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). + ## 2026.1.19-3 ### Changes diff --git a/docs/perplexity.md b/docs/perplexity.md index 829c2f25f..29434e2db 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -64,8 +64,11 @@ If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set `tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`) to disambiguate. -If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set, -Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override. +If no base URL is set, Clawdbot chooses a default based on the API key source: + +- `PERPLEXITY_API_KEY` or `pplx-...` → direct Perplexity (`https://api.perplexity.ai`) +- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`) +- Unknown key formats → OpenRouter (safe fallback) ## Models diff --git a/docs/tools/web.md b/docs/tools/web.md index 3780538a5..da73fe34e 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -10,7 +10,7 @@ read_when: Clawdbot ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (via OpenRouter). +- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -31,7 +31,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | Provider | Pros | Cons | API Key | |----------|------|------|---------| | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | -| **Perplexity** | AI-synthesized answers, citations, real-time | Requires OpenRouter credits | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. @@ -110,7 +110,7 @@ crypto/prepaid). perplexity: { // API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set) apiKey: "sk-or-v1-...", - // Base URL (defaults to OpenRouter) + // Base URL (key-aware default if omitted) baseUrl: "https://openrouter.ai/api/v1", // Model (defaults to perplexity/sonar-pro) model: "perplexity/sonar-pro" @@ -124,8 +124,11 @@ crypto/prepaid). **Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway environment. For a daemon install, put it in `~/.clawdbot/.env`. -If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set, -Clawdbot defaults to the direct Perplexity endpoint (`https://api.perplexity.ai`). +If no base URL is set, Clawdbot chooses a default based on the API key source: + +- `PERPLEXITY_API_KEY` or `pplx-...` → `https://api.perplexity.ai` +- `OPENROUTER_API_KEY` or `sk-or-...` → `https://openrouter.ai/api/v1` +- Unknown key formats → OpenRouter (safe fallback) ### Available Perplexity models diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts new file mode 100644 index 000000000..51b6e5c9e --- /dev/null +++ b/src/agents/tools/web-search.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { __testing } from "./web-search.js"; + +const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing; + +describe("web_search perplexity baseUrl defaults", () => { + it("detects a Perplexity key prefix", () => { + expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct"); + }); + + it("detects an OpenRouter key prefix", () => { + expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter"); + }); + + it("returns undefined for unknown key formats", () => { + expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined(); + }); + + it("prefers explicit baseUrl over key-based defaults", () => { + expect( + resolvePerplexityBaseUrl( + { baseUrl: "https://example.com" }, + "config", + "pplx-123", + ), + ).toBe("https://example.com"); + }); + + it("defaults to direct when using PERPLEXITY_API_KEY", () => { + expect(resolvePerplexityBaseUrl(undefined, "perplexity_env")).toBe( + "https://api.perplexity.ai", + ); + }); + + it("defaults to OpenRouter when using OPENROUTER_API_KEY", () => { + expect(resolvePerplexityBaseUrl(undefined, "openrouter_env")).toBe( + "https://openrouter.ai/api/v1", + ); + }); + + it("defaults to direct when config key looks like Perplexity", () => { + expect(resolvePerplexityBaseUrl(undefined, "config", "pplx-123")).toBe( + "https://api.perplexity.ai", + ); + }); + + it("defaults to OpenRouter when config key looks like OpenRouter", () => { + expect(resolvePerplexityBaseUrl(undefined, "config", "sk-or-v1-123")).toBe( + "https://openrouter.ai/api/v1", + ); + }); + + it("defaults to OpenRouter for unknown config key formats", () => { + expect(resolvePerplexityBaseUrl(undefined, "config", "weird-key")).toBe( + "https://openrouter.ai/api/v1", + ); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1a236e251..b2d61d4cb 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -24,6 +24,8 @@ const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const SEARCH_CACHE = new Map>>(); @@ -90,6 +92,8 @@ type PerplexitySearchResponse = { citations?: string[]; }; +type PerplexityBaseUrlHint = "direct" | "openrouter"; + function resolveSearchConfig(cfg?: ClawdbotConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") return undefined; @@ -147,20 +151,17 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { apiKey?: string; source: PerplexityApiKeySource; } { - const fromConfig = - perplexity && "apiKey" in perplexity && typeof perplexity.apiKey === "string" - ? perplexity.apiKey.trim() - : ""; + const fromConfig = normalizeApiKey(perplexity?.apiKey); if (fromConfig) { return { apiKey: fromConfig, source: "config" }; } - const fromEnvPerplexity = (process.env.PERPLEXITY_API_KEY ?? "").trim(); + const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); if (fromEnvPerplexity) { return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; } - const fromEnvOpenRouter = (process.env.OPENROUTER_API_KEY ?? "").trim(); + const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); if (fromEnvOpenRouter) { return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; } @@ -168,9 +169,26 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { return { apiKey: undefined, source: "none" }; } +function normalizeApiKey(key: unknown): string { + return typeof key === "string" ? key.trim() : ""; +} + +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { + if (!apiKey) return undefined; + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + function resolvePerplexityBaseUrl( perplexity?: PerplexityConfig, apiKeySource: PerplexityApiKeySource = "none", + apiKey?: string, ): string { const fromConfig = perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" @@ -178,6 +196,12 @@ function resolvePerplexityBaseUrl( : ""; if (fromConfig) return fromConfig; if (apiKeySource === "perplexity_env") return PERPLEXITY_DIRECT_BASE_URL; + if (apiKeySource === "openrouter_env") return DEFAULT_PERPLEXITY_BASE_URL; + if (apiKeySource === "config") { + const inferred = inferPerplexityBaseUrlFromApiKey(apiKey); + if (inferred === "direct") return PERPLEXITY_DIRECT_BASE_URL; + if (inferred === "openrouter") return DEFAULT_PERPLEXITY_BASE_URL; + } return DEFAULT_PERPLEXITY_BASE_URL; } @@ -385,10 +409,19 @@ export function createWebSearchTool(options?: { country, search_lang, ui_lang, - perplexityBaseUrl: resolvePerplexityBaseUrl(perplexityConfig, perplexityAuth?.source), + perplexityBaseUrl: resolvePerplexityBaseUrl( + perplexityConfig, + perplexityAuth?.source, + perplexityAuth?.apiKey, + ), perplexityModel: resolvePerplexityModel(perplexityConfig), }); return jsonResult(result); }, }; } + +export const __testing = { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, +} as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index c244409c0..b100bc9bd 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -194,7 +194,7 @@ describe("web_search perplexity baseUrl defaults", () => { expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/pplx/chat/completions"); }); - it("defaults to OpenRouter when apiKey is configured without baseUrl", async () => { + it("defaults to Perplexity direct when apiKey looks like Perplexity", async () => { const mockFetch = vi.fn(() => Promise.resolve({ ok: true, @@ -219,6 +219,35 @@ describe("web_search perplexity baseUrl defaults", () => { }); await tool?.execute?.(1, { query: "test-config-apikey" }); + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions"); + }); + + it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => { + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }), + } as Response), + ); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "perplexity", + perplexity: { apiKey: "sk-or-v1-test" }, + }, + }, + }, + }, + sandboxed: true, + }); + await tool?.execute?.(1, { query: "test-openrouter-config" }); + expect(mockFetch).toHaveBeenCalled(); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); });