feat(perplexity): refresh provider integrations (#7687)

This commit is contained in:
backryun
2026-07-20 02:37:44 +09:00
committed by GitHub
parent a95da4a902
commit 649e5d09e7
16 changed files with 310 additions and 165 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 () => {

View File

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

View 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/);
});

View File

@@ -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 () => {