fix: infer perplexity baseUrl from api key

This commit is contained in:
Peter Steinberger
2026-01-20 07:27:25 +00:00
parent 52e9450a79
commit 3e546e691d
6 changed files with 147 additions and 15 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
);
});
});

View File

@@ -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<string, CacheEntry<Record<string, unknown>>>();
@@ -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;

View File

@@ -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");
});