Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/7687-perplexity-refresh.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 2 additions & 5 deletions open-sse/config/providers/registry/perplexity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
5 changes: 3 additions & 2 deletions open-sse/config/providers/registry/perplexity/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)" },
],
};
2 changes: 2 additions & 0 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions open-sse/executors/perplexity-web/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 8 additions & 18 deletions open-sse/handlers/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
101 changes: 101 additions & 0 deletions open-sse/handlers/search/perplexitySearch.ts
Original file line number Diff line number Diff line change
@@ -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),
},
};
}
8 changes: 7 additions & 1 deletion src/app/api/v1/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/validation/schemas/apiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 25 additions & 41 deletions tests/unit/perplexity-key-validation-models.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions tests/unit/perplexity-web-model-mappings.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading