mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(perplexity): refresh provider integrations (#7687)
This commit is contained in:
1
changelog.d/features/7687-perplexity-refresh.md
Normal file
1
changelog.d/features/7687-perplexity-refresh.md
Normal file
@@ -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
|
||||
@@ -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 `<baseUrl>/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: [
|
||||
|
||||
@@ -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)" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,22 +48,24 @@ export const PPLX_USER_AGENT =
|
||||
export const MODEL_MAP: Record<string, [string, string]> = {
|
||||
"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<string, string> = {
|
||||
"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;
|
||||
|
||||
@@ -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<string, unknown> = { 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<Searc
|
||||
providerOptions,
|
||||
};
|
||||
|
||||
if (primaryConfig.id === "perplexity-search") {
|
||||
const perplexityValidation = parsePerplexitySearchOptions(requestParams);
|
||||
if (perplexityValidation.error) {
|
||||
return { success: false, status: 400, error: perplexityValidation.error };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try primary provider
|
||||
const result = await tryProvider(primaryConfig, requestParams, credentials, startTime, log);
|
||||
|
||||
|
||||
101
open-sse/handlers/search/perplexitySearch.ts
Normal file
101
open-sse/handlers/search/perplexitySearch.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
|
||||
|
||||
interface PerplexitySearchParams {
|
||||
query: string;
|
||||
maxResults: number;
|
||||
token?: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
timeRange?: string;
|
||||
domainFilter?: string[];
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<typeof searchOptionsSchema>;
|
||||
|
||||
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<string, unknown> = {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<baseUrl>/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 <baseUrl>/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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
44
tests/unit/perplexity-web-model-mappings.test.ts
Normal file
44
tests/unit/perplexity-web-model-mappings.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
102
tests/unit/search-handler-perplexity-options.test.ts
Normal file
102
tests/unit/search-handler-perplexity-options.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user