diff --git a/changelog.d/features/7687-perplexity-refresh.md b/changelog.d/features/7687-perplexity-refresh.md new file mode 100644 index 0000000000..8444a1a320 --- /dev/null +++ b/changelog.d/features/7687-perplexity-refresh.md @@ -0,0 +1 @@ +- **feat(perplexity):** Refresh Perplexity Web model mappings and Search API filters while keeping `/v1/models` scoped to API-key validation instead of curated catalog discovery ([#7687](https://github.com/diegosouzapw/OmniRoute/pull/7687)) — thanks @backryun diff --git a/open-sse/config/providers/registry/perplexity/index.ts b/open-sse/config/providers/registry/perplexity/index.ts index 14b90d413d..a8857fdcc9 100644 --- a/open-sse/config/providers/registry/perplexity/index.ts +++ b/open-sse/config/providers/registry/perplexity/index.ts @@ -6,11 +6,8 @@ export const perplexityProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://api.perplexity.ai/chat/completions", - // Perplexity deprecated the unversioned `/models` endpoint (returns 404), so - // pin an explicit `modelsUrl` here. Without it, validateOpenAILikeProvider - // (src/lib/providers/validation.ts) derives `/models` via - // addModelsSuffix and probes the dead endpoint, misclassifying valid keys. - modelsUrl: "https://api.perplexity.ai/v1/models", + // `/v1/models` lists the Agent API catalog, so use it for key validation only. + testKeyModelsUrl: "https://api.perplexity.ai/v1/models", authType: "apikey", authHeader: "bearer", models: [ diff --git a/open-sse/config/providers/registry/perplexity/web/index.ts b/open-sse/config/providers/registry/perplexity/web/index.ts index 36715483f9..5eb8261350 100644 --- a/open-sse/config/providers/registry/perplexity/web/index.ts +++ b/open-sse/config/providers/registry/perplexity/web/index.ts @@ -11,13 +11,14 @@ export const perplexity_webProvider: RegistryEntry = { models: [ { id: "pplx-auto", name: "Perplexity Best" }, { id: "pplx-sonar", name: "Sonar 2 (via Perplexity)" }, - { id: "pplx-gpt-5.4", name: "GPT-5.4 (via Perplexity)" }, - { id: "pplx-gpt", name: "GPT-5.5 (via Perplexity)" }, + { id: "pplx-gpt-5.6-terra", name: "GPT-5.6 Terra (via Perplexity)" }, + { id: "pplx-gpt-5.6-sol", name: "GPT-5.6 Sol (via Perplexity)" }, { id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)" }, { id: "pplx-sonnet", name: "Claude Sonnet 5.0 (via Perplexity)" }, { id: "pplx-opus", name: "Claude Opus 4.8 (via Perplexity)" }, { id: "pplx-glm", name: "GLM-5.2 (via Perplexity)" }, { id: "pplx-kimi", name: "Kimi K2.6 (via Perplexity)" }, + { id: "pplx-grok-4.5", name: "Grok 4.5 (via Perplexity)" }, { id: "pplx-nemotron", name: "Nemotron 3 Ultra (via Perplexity)" }, ], }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 6ab575c7ee..fd32da1ad6 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -109,6 +109,8 @@ export interface RegistryEntry { baseUrls?: string[]; /** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */ testKeyBaseUrl?: string; + /** Override models URL used only for API key validation, not catalog discovery. */ + testKeyModelsUrl?: string; responsesBaseUrl?: string; /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used * for models tagged `targetFormat: "claude"` on an otherwise openai-format diff --git a/open-sse/executors/perplexity-web/protocol.ts b/open-sse/executors/perplexity-web/protocol.ts index 25b5aa41fb..8a0434aeba 100644 --- a/open-sse/executors/perplexity-web/protocol.ts +++ b/open-sse/executors/perplexity-web/protocol.ts @@ -48,22 +48,24 @@ export const PPLX_USER_AGENT = export const MODEL_MAP: Record = { "pplx-auto": ["search", "pplx_pro"], "pplx-sonar": ["search", "experimental"], - "pplx-gpt-5.4": ["search", "gpt54"], - "pplx-gpt": ["search", "gpt55"], + "pplx-gpt-5.6-terra": ["search", "gpt56_terra"], + "pplx-gpt-5.6-sol": ["search", "gpt56_sol"], "pplx-gemini": ["search", "gemini31pro_high"], "pplx-sonnet": ["search", "claude50sonnet"], "pplx-opus": ["search", "claude48opus"], "pplx-glm": ["search", "glm_5_2"], "pplx-kimi": ["search", "kimik26instant"], + "pplx-grok-4.5": ["search", "grok45low"], "pplx-nemotron": ["search", "nv_nemotron_3_ultra"], }; export const THINKING_MAP: Record = { - "pplx-gpt-5.4": "gpt54_thinking", - "pplx-gpt": "gpt55_thinking", + "pplx-gpt-5.6-terra": "gpt56_terra_thinking", + "pplx-gpt-5.6-sol": "gpt56_sol_thinking", "pplx-sonnet": "claude50sonnetthinking", "pplx-opus": "claude48opusthinking", "pplx-kimi": "kimik26thinking", + "pplx-grok-4.5": "grok45medium", }; export const CITATION_RE = /\[\d+\]/g; diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 262e10d220..149b7b4e86 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -17,6 +17,7 @@ import { randomUUID } from "crypto"; */ import { getSearchProvider, type SearchProviderConfig } from "../config/searchRegistry.ts"; +import { buildPerplexityRequest, parsePerplexitySearchOptions } from "./search/perplexitySearch.ts"; import { freeWebSearch } from "../services/freeWebSearch.ts"; import { saveCallLog } from "@/lib/usageDb"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; @@ -322,24 +323,6 @@ function buildBraveRequest( }; } -function buildPerplexityRequest( - config: SearchProviderConfig, - params: SearchRequestParams -): { url: string; init: RequestInit } { - const body: Record = { query: params.query, max_results: params.maxResults }; - if (params.country) body.country = params.country; - if (params.language) body.search_language_filter = [params.language]; - if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; - return { - url: config.baseUrl, - init: { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, - body: JSON.stringify(body), - }, - }; -} - function buildExaRequest( config: SearchProviderConfig, params: SearchRequestParams @@ -1247,6 +1230,13 @@ export async function handleSearch(options: SearchHandlerOptions): Promise; +} + +const searchDateSchema = z + .string() + .regex(/^(0[1-9]|1[0-2])\/(0[1-9]|[12]\d|3[01])\/\d{4}$/, "Expected MM/DD/YYYY"); + +const searchOptionsSchema = z.object({ + max_tokens: z.coerce.number().int().min(1).max(1_000_000).optional(), + max_tokens_per_page: z.coerce.number().int().min(1).max(1_000_000).optional(), + search_language_filter: z + .array( + z + .string() + .regex(/^[A-Za-z]{2}$/, "Expected a two-letter ISO 639-1 language code") + .transform((value) => value.toLowerCase()) + ) + .max(10) + .optional(), + last_updated_after_filter: searchDateSchema.optional(), + last_updated_before_filter: searchDateSchema.optional(), + search_after_date_filter: searchDateSchema.optional(), + search_before_date_filter: searchDateSchema.optional(), + search_recency_filter: z.enum(["hour", "day", "week", "month", "year"]).optional(), +}); + +type PerplexitySearchOptions = z.infer; + +function normalizeLanguage(language?: string): string | undefined { + const primary = language?.split(/[-_]/, 1)[0]?.toLowerCase(); + return primary && /^[a-z]{2}$/.test(primary) ? primary : undefined; +} + +export function parsePerplexitySearchOptions(params: PerplexitySearchParams): { + options?: PerplexitySearchOptions; + error?: string; +} { + const parsed = searchOptionsSchema.safeParse(params.providerOptions ?? {}); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return { error: `Invalid Perplexity Search option: ${issue?.message || "invalid value"}` }; + } + + const includes = params.domainFilter?.some((domain) => !domain.startsWith("-")); + const excludes = params.domainFilter?.some((domain) => domain.startsWith("-")); + if (includes && excludes) { + return { + error: + "Perplexity Search domain filters must use either include_domains or exclude_domains, not both", + }; + } + + if ( + params.language && + !parsed.data.search_language_filter && + !normalizeLanguage(params.language) + ) { + return { error: "Perplexity Search language must be a two-letter ISO 639-1 code" }; + } + + return { options: parsed.data }; +} + +export function buildPerplexityRequest( + config: SearchProviderConfig, + params: PerplexitySearchParams +): { url: string; init: RequestInit } { + const options = parsePerplexitySearchOptions(params).options ?? {}; + const body: Record = { + query: params.query, + max_results: params.maxResults, + ...options, + }; + if (params.country) body.country = params.country.toUpperCase(); + const language = normalizeLanguage(params.language); + if (language && !body.search_language_filter) body.search_language_filter = [language]; + if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; + if (params.timeRange && params.timeRange !== "any" && !body.search_recency_filter) { + body.search_recency_filter = params.timeRange; + } + return { + url: config.baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 3290f26d29..95a43e93f5 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -270,7 +270,13 @@ async function postHandler(request: Request, context: unknown) { clampedMaxResults, body.country, body.language, - { filters: body.filters, offset: body.offset, time_range: body.time_range } + { + filters: body.filters, + offset: body.offset, + time_range: body.time_range, + content: body.content, + provider_options: body.provider_options, + } ); const ttl = providerConfig.cacheTTLMs ?? SEARCH_CACHE_DEFAULT_TTL_MS; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index dbcd2041a2..6521b5c03b 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -833,7 +833,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi headers: entry.headers || {}, providerSpecificData, modelId, - modelsUrl: entry.modelsUrl, + modelsUrl: entry.testKeyModelsUrl || entry.modelsUrl, isLocal, }); } diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index c23d227f2f..7efc45fa48 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -194,7 +194,7 @@ export const v1SearchSchema = z // Locale country: z.string().max(2).toUpperCase().optional(), language: z.string().min(2).max(5).optional(), - time_range: z.enum(["any", "day", "week", "month", "year"]).optional(), + time_range: z.enum(["any", "hour", "day", "week", "month", "year"]).optional(), // Content control content: z diff --git a/tests/unit/perplexity-key-validation-models.test.ts b/tests/unit/perplexity-key-validation-models.test.ts index ee078ea5cb..2408a6919f 100644 --- a/tests/unit/perplexity-key-validation-models.test.ts +++ b/tests/unit/perplexity-key-validation-models.test.ts @@ -1,50 +1,34 @@ -// Regression test for perplexity API key validation. -// -// Perplexity deprecated the unversioned `/models` endpoint (returns 404), so -// our default validation probe — which derives `/models` from the -// perplexity registry entry via `addModelsSuffix` — would always fail to -// confirm a valid key, falling through to the chat-completions probe and -// often misclassifying live keys as "Invalid". Inspired by upstream -// 9router fix (see commit message); we port it OmniRoute-style by -// declaring an explicit `modelsUrl` on the perplexity registry entry. - import { describe, it } from "node:test"; -import assert from "node:assert"; +import assert from "node:assert/strict"; + +const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); +const { deriveConfigFromRegistryModelsUrl } = + await import("../../src/app/api/providers/[id]/models/discoveryConfig.ts"); +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); describe("perplexity registry — key validation models endpoint", () => { - it("declares a modelsUrl pointing at /v1/models (not the deprecated /models)", async () => { - const { getRegistryEntry } = await import( - "../../open-sse/config/providerRegistry.ts" - ); + it("keeps /v1/models for validation without enabling Agent catalog discovery", () => { const entry = getRegistryEntry("perplexity"); assert.ok(entry, "perplexity must be registered in the execution registry"); - assert.equal(entry.format, "openai"); - assert.ok( - typeof entry.modelsUrl === "string" && entry.modelsUrl.length > 0, - "perplexity registry entry must declare an explicit modelsUrl so key " + - "validation does not hit the deprecated /models endpoint" - ); - assert.equal( - entry.modelsUrl, - "https://api.perplexity.ai/v1/models", - "perplexity modelsUrl must point at the versioned /v1/models endpoint " + - "(unversioned /models was deprecated and now returns 404)" - ); + assert.equal(entry.testKeyModelsUrl, "https://api.perplexity.ai/v1/models"); + assert.equal(entry.modelsUrl, undefined); + assert.equal(deriveConfigFromRegistryModelsUrl("perplexity"), undefined); }); - it("does not derive a /models URL that ends in /chat/completions/models", async () => { - const { getRegistryEntry } = await import( - "../../open-sse/config/providerRegistry.ts" - ); - const entry = getRegistryEntry("perplexity"); - assert.ok(entry?.modelsUrl, "modelsUrl required"); - assert.ok( - !entry.modelsUrl.includes("/chat/completions"), - "modelsUrl must not include /chat/completions" - ); - assert.ok( - entry.modelsUrl.endsWith("/v1/models"), - "modelsUrl must end with /v1/models" - ); + it("uses /v1/models when validating a Perplexity API key", async () => { + const originalFetch = globalThis.fetch; + const urls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }) as typeof fetch; + + try { + const result = await validateProviderApiKey({ provider: "perplexity", apiKey: "pplx-test" }); + assert.equal(result.valid, true); + assert.deepEqual(urls, ["https://api.perplexity.ai/v1/models"]); + } finally { + globalThis.fetch = originalFetch; + } }); }); diff --git a/tests/unit/perplexity-web-model-mappings.test.ts b/tests/unit/perplexity-web-model-mappings.test.ts new file mode 100644 index 0000000000..98d3f4d715 --- /dev/null +++ b/tests/unit/perplexity-web-model-mappings.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-perplexity-models-")); + +const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); +const { MODEL_MAP, THINKING_MAP } = + await import("../../open-sse/executors/perplexity-web/protocol.ts"); + +test("Perplexity Web registers the refreshed model catalog", () => { + const models = PROVIDER_MODELS["pplx-web"]; + assert.ok(models, "pplx-web should be in PROVIDER_MODELS"); + assert.equal(models.length, 11); + + const modelIds = models.map((model) => model.id); + const expectedModelIds = [ + "pplx-auto", + "pplx-gpt-5.6-terra", + "pplx-gpt-5.6-sol", + "pplx-sonnet", + "pplx-opus", + "pplx-gemini", + "pplx-nemotron", + "pplx-sonar", + "pplx-kimi", + "pplx-glm", + "pplx-grok-4.5", + ]; + assert.deepEqual([...modelIds].sort(), expectedModelIds.sort()); +}); + +test("every advertised Perplexity Web model has an explicit internal mapping", () => { + const missing = PROVIDER_MODELS["pplx-web"].filter((model) => !MODEL_MAP[model.id]); + assert.deepEqual(missing, []); + assert.deepEqual(MODEL_MAP["pplx-gpt-5.6-terra"], ["search", "gpt56_terra"]); + assert.deepEqual(MODEL_MAP["pplx-gpt-5.6-sol"], ["search", "gpt56_sol"]); + assert.deepEqual(MODEL_MAP["pplx-grok-4.5"], ["search", "grok45low"]); + assert.equal(THINKING_MAP["pplx-gpt-5.6-terra"], "gpt56_terra_thinking"); + assert.equal(THINKING_MAP["pplx-gpt-5.6-sol"], "gpt56_sol_thinking"); + assert.equal(THINKING_MAP["pplx-grok-4.5"], "grok45medium"); +}); diff --git a/tests/unit/perplexity-web.test.ts b/tests/unit/perplexity-web.test.ts index 5b73a0a746..24f24b069e 100644 --- a/tests/unit/perplexity-web.test.ts +++ b/tests/unit/perplexity-web.test.ts @@ -160,7 +160,7 @@ test("Non-streaming: strips citations from response", async () => { try { const executor = new PerplexityWebExecutor(); const result = await executor.execute({ - model: "pplx-gpt", + model: "pplx-sonar", body: { messages: [{ role: "user", content: "meaning of life" }], stream: false }, stream: false, credentials: { apiKey: "test-cookie" }, @@ -487,7 +487,7 @@ test("Error: 429 returns rate limit message", async () => { try { const executor = new PerplexityWebExecutor(); const result = await executor.execute({ - model: "pplx-gpt", + model: "pplx-sonar", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test-cookie" }, @@ -765,7 +765,7 @@ test("Auth: JWT auth sends Authorization Bearer header", async () => { // ─── Test: Model mapping ──────────────────────────────────────────────────── -test("Model mapping: pplx-gpt sends current GPT-5.5 internal preference", async () => { +test("Model mapping: GPT-5.6 Terra sends its current internal preference", async () => { let capturedBody = null; const original = globalThis.fetch; globalThis.fetch = async (url, opts) => { @@ -789,7 +789,7 @@ test("Model mapping: pplx-gpt sends current GPT-5.5 internal preference", async try { const executor = new PerplexityWebExecutor(); await executor.execute({ - model: "pplx-gpt", + model: "pplx-gpt-5.6-terra", body: { messages: [{ role: "user", content: "test" }], stream: false }, stream: false, credentials: { apiKey: "test" }, @@ -797,7 +797,7 @@ test("Model mapping: pplx-gpt sends current GPT-5.5 internal preference", async log: null, }); - assert.equal(capturedBody.params.model_preference, "gpt55"); + assert.equal(capturedBody.params.model_preference, "gpt56_terra"); assert.equal(capturedBody.params.mode, "search"); } finally { globalThis.fetch = original; @@ -847,40 +847,6 @@ test("Model mapping: thinking mode uses thinking variant", async () => { } }); -// ─── Test: Provider registry ──────────────────────────────────────────────── - -test("Provider registry: perplexity-web is registered with correct models", async () => { - const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); - - const models = PROVIDER_MODELS["pplx-web"]; - assert.ok(models, "pplx-web should be in PROVIDER_MODELS"); - assert.ok(models.length === 10, `Expected 10 models, got ${models.length}`); - - const modelIds = models.map((m) => m.id); - assert.ok(modelIds.includes("pplx-auto")); - assert.ok(modelIds.includes("pplx-gpt")); - assert.ok(modelIds.includes("pplx-gpt-5.4")); - assert.ok(modelIds.includes("pplx-sonnet")); - assert.ok(modelIds.includes("pplx-opus")); - assert.ok(modelIds.includes("pplx-gemini")); - assert.ok(modelIds.includes("pplx-nemotron")); - assert.ok(modelIds.includes("pplx-sonar")); - assert.ok(modelIds.includes("pplx-kimi")); - assert.ok(modelIds.includes("pplx-glm")); -}); - -test("Provider registry: every advertised perplexity-web model has an explicit internal mapping", async () => { - const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); - const { MODEL_MAP } = await import("../../open-sse/executors/perplexity-web/protocol.ts"); - - const missing = PROVIDER_MODELS["pplx-web"].filter((model) => !MODEL_MAP[model.id]); - assert.deepEqual( - missing.map((model) => model.id), - [], - "all advertised Perplexity Web models should map to an explicit model_preference" - ); -}); - // ─── Test: Fallback text field ────────────────────────────────────────────── test("Non-streaming: falls back to text field when no blocks", async () => { diff --git a/tests/unit/search-handler-extended.test.ts b/tests/unit/search-handler-extended.test.ts index 5a0e206a44..8d1449b56f 100644 --- a/tests/unit/search-handler-extended.test.ts +++ b/tests/unit/search-handler-extended.test.ts @@ -123,59 +123,6 @@ test("handleSearch builds Brave news requests and normalizes favicon metadata", } }); -test("handleSearch builds Perplexity requests, forwards filters, and enforces maxResults", async () => { - const originalFetch = globalThis.fetch; - let captured; - - globalThis.fetch = async (url, init = {}) => { - captured = { - url: String(url), - headers: init.headers, - body: JSON.parse(String(init.body || "{}")), - }; - - return new Response( - JSON.stringify({ - results: [ - { title: "One", url: "https://one.example.com", snippet: "First", date: "2026-04-05" }, - { title: "Two", url: "https://two.example.com", snippet: "Second" }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - try { - const result = await handleSearch({ - query: "ai agents", - provider: "perplexity-search", - maxResults: 1, - searchType: "web", - country: "US", - language: "en", - domainFilter: ["example.com", "-spam.com"], - credentials: { apiKey: "perplexity-key" }, - log: null, - }); - - assert.equal(captured.url, "https://api.perplexity.ai/search"); - assert.equal(captured.headers.Authorization, "Bearer perplexity-key"); - assert.deepEqual(captured.body, { - query: "ai agents", - max_results: 1, - country: "US", - search_language_filter: ["en"], - search_domain_filter: ["example.com", "-spam.com"], - }); - assert.equal(result.success, true); - assert.equal(result.data.results.length, 1); - assert.equal(result.data.usage.queries_used, 1); - assert.equal(result.data.usage.search_cost_usd, 0.005); - } finally { - globalThis.fetch = originalFetch; - } -}); - test("handleSearch builds Exa requests with include/exclude domains and preserves rich result fields", async () => { const originalFetch = globalThis.fetch; let captured; diff --git a/tests/unit/search-handler-perplexity-options.test.ts b/tests/unit/search-handler-perplexity-options.test.ts new file mode 100644 index 0000000000..9e17f93013 --- /dev/null +++ b/tests/unit/search-handler-perplexity-options.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-perplexity-search-")); + +const { handleSearch } = await import("../../open-sse/handlers/search.ts"); + +test("Perplexity Search forwards validated provider options and locale filters", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + headers: init.headers, + body: JSON.parse(String(init.body || "{}")), + }; + return new Response( + JSON.stringify({ + results: [ + { title: "One", url: "https://one.example.com", snippet: "First" }, + { title: "Two", url: "https://two.example.com", snippet: "Second" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleSearch({ + query: "ai agents", + provider: "perplexity-search", + maxResults: 1, + searchType: "web", + country: "US", + language: "en", + timeRange: "week", + domainFilter: ["example.com"], + providerOptions: { + max_tokens: 4000, + max_tokens_per_page: 1000, + search_language_filter: ["EN", "ko"], + last_updated_after_filter: "06/01/2026", + search_before_date_filter: "07/01/2026", + }, + credentials: { apiKey: "perplexity-key" }, + log: null, + }); + + assert.equal(captured.url, "https://api.perplexity.ai/search"); + assert.equal(captured.headers.Authorization, "Bearer perplexity-key"); + assert.deepEqual(captured.body, { + query: "ai agents", + max_results: 1, + country: "US", + search_language_filter: ["en", "ko"], + search_domain_filter: ["example.com"], + search_recency_filter: "week", + max_tokens: 4000, + max_tokens_per_page: 1000, + last_updated_after_filter: "06/01/2026", + search_before_date_filter: "07/01/2026", + }); + assert.equal(result.success, true); + assert.equal(result.data.results.length, 1); + assert.equal(result.data.usage.queries_used, 1); + assert.equal(result.data.usage.search_cost_usd, 0.005); + + const localeResult = await handleSearch({ + query: "localized search", + provider: "perplexity-search", + maxResults: 1, + searchType: "web", + language: "en-US", + credentials: { apiKey: "perplexity-key" }, + log: null, + }); + assert.equal(localeResult.success, true); + assert.deepEqual(captured.body.search_language_filter, ["en"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Perplexity Search rejects mixed domain allowlist and denylist filters", async () => { + const result = await handleSearch({ + query: "ai agents", + provider: "perplexity-search", + maxResults: 5, + searchType: "web", + domainFilter: ["example.com", "-spam.com"], + credentials: { apiKey: "perplexity-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.match(result.error, /either include_domains or exclude_domains/); +}); diff --git a/tests/unit/search-registry.test.ts b/tests/unit/search-registry.test.ts index d51c5fbb22..929ead99ee 100644 --- a/tests/unit/search-registry.test.ts +++ b/tests/unit/search-registry.test.ts @@ -353,11 +353,13 @@ test("v1SearchSchema validates correct input", async () => { provider: "serper-search", max_results: 10, search_type: "web", + time_range: "hour", }); assert.ok(result.success); assert.equal(result.data.query, "test query"); assert.equal(result.data.provider, "serper-search"); assert.equal(result.data.max_results, 10); + assert.equal(result.data.time_range, "hour"); }); test("v1SearchSchema rejects empty query", async () => {