diff --git a/changelog.d/features/perplexity-agent-provider.md b/changelog.d/features/perplexity-agent-provider.md new file mode 100644 index 000000000000..9e118f90af46 --- /dev/null +++ b/changelog.d/features/perplexity-agent-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add a Perplexity Agent API provider (`perplexity-agent` / `pplx-agent`) for Perplexity `/v1/responses`, including the documented Anthropic, OpenAI, Google, xAI, DeepSeek, Z.AI, Moonshot/Kimi, NVIDIA, and Perplexity model IDs plus Anthropic-model `max_output_tokens` compatibility. diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 23ad898973b0..99d2a4f39d44 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-08-28 +lastUpdated: 2026-08-30 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-08-28 +> **Last generated:** 2026-08-30 -Total providers: **351**. See category breakdown below. +Total providers: **352**. See category breakdown below. ## Categories @@ -116,7 +116,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — | -## API Key Providers (paid / paid-with-free-credits) (235) +## API Key Providers (paid / paid-with-free-credits) (236) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -193,11 +193,11 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | | `freetheai` | `fta` | FreeTheAi | API key, aggregator | [link](https://freetheai.xyz) | Join the FreeTheAi Discord to get your free API key. | | `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | -| `g4f-gemini` | `g4fgem` | g4f.space — Gemini | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html. | -| `g4f-groq` | `g4fgroq` | g4f.space — Groq | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html. | -| `g4f-nvidia` | `g4fnv` | g4f.space — NVIDIA | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html. | -| `g4f-ollama` | `g4foll` | g4f.space — Ollama | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html. | -| `g4f-pollinations` | `g4fpol` | g4f.space — Pollinations | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html. | +| `g4f-gemini` | `g4fgem` | g4f.space — Gemini | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html). | +| `g4f-groq` | `g4fgroq` | g4f.space — Groq | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html). | +| `g4f-nvidia` | `g4fnv` | g4f.space — NVIDIA | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html). | +| `g4f-ollama` | `g4foll` | g4f.space — Ollama | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html). | +| `g4f-pollinations` | `g4fpol` | g4f.space — Pollinations | API key, aggregator | [link](https://g4f.space) | Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html). | | `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | | `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free tier available through Google AI Studio; current per-model quotas and regional limits apply | | `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | @@ -283,6 +283,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | | `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | | `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | +| `perplexity-agent` | `pplx-agent` | Perplexity Agent | API key | [link](https://www.perplexity.ai) | Use your Perplexity API key. OmniRoute routes Agent API model IDs through Perplexity's Responses-compatible endpoint. | | `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | | `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | | `plamo` | `plamo` | PLaMo | API key | [link](https://plamo.preferredai.jp/api) | — | diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index bd49155ba540..cc8ec3a703ab 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -111,6 +111,7 @@ import { moonshotProvider } from "./registry/moonshot/index.ts"; import { poeProvider } from "./registry/poe/index.ts"; import { bazaarlinkProvider } from "./registry/bazaarlink/index.ts"; import { perplexityProvider } from "./registry/perplexity/index.ts"; +import { perplexityAgentProvider } from "./registry/perplexity/agent/index.ts"; import { perplexity_webProvider } from "./registry/perplexity/web/index.ts"; import { minimaxProvider } from "./registry/minimax/index.ts"; import { minimax_cnProvider } from "./registry/minimax/cn/index.ts"; @@ -376,6 +377,7 @@ export const REGISTRY: Record = { poe: poeProvider, bazaarlink: bazaarlinkProvider, perplexity: perplexityProvider, + "perplexity-agent": perplexityAgentProvider, "perplexity-web": perplexity_webProvider, minimax: minimaxProvider, "minimax-cn": minimax_cnProvider, diff --git a/open-sse/config/providers/registry/perplexity/agent/index.ts b/open-sse/config/providers/registry/perplexity/agent/index.ts new file mode 100644 index 000000000000..9214136cc77d --- /dev/null +++ b/open-sse/config/providers/registry/perplexity/agent/index.ts @@ -0,0 +1,29 @@ +import type { RegistryEntry } from "../../../shared.ts"; + +export const perplexityAgentProvider: RegistryEntry = { + id: "perplexity-agent", + alias: "pplx-agent", + format: "openai-responses", + executor: "default", + baseUrl: "https://api.perplexity.ai/v1/responses", + modelsUrl: "https://api.perplexity.ai/v1/models", + testKeyModelsUrl: "https://api.perplexity.ai/v1/models", + authType: "apikey", + authHeader: "bearer", + passthroughModels: true, + liveCatalogAuthoritative: false, + models: [ + { + id: "openai/gpt-5.6-sol", + name: "GPT-5.6 Sol (Perplexity Agent)", + supportsReasoning: true, + toolCalling: true, + }, + { + id: "perplexity/kimi-k3", + name: "Kimi K3 (Perplexity Agent)", + supportsReasoning: true, + supportedThinkingEfforts: ["minimal", "low", "medium", "high", "xhigh", "max"], + }, + ], +}; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 0d2c9182bd45..00a79b2688e1 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -67,6 +67,25 @@ import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProvide import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; const NVIDIA_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9]{9}$/; +const PERPLEXITY_AGENT_DEFAULT_MAX_OUTPUT_TOKENS = 4096; + +function defaultPerplexityAgentMaxOutputTokens(body: T): T { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + + const record = body as Record; + if ( + record.max_output_tokens !== undefined || + record.max_completion_tokens !== undefined || + record.max_tokens !== undefined + ) { + return body; + } + + return { + ...record, + max_output_tokens: PERPLEXITY_AGENT_DEFAULT_MAX_OUTPUT_TOKENS, + } as T; +} function normalizeNvidiaToolCallId(id: unknown): unknown { if (id === null || id === undefined) return id; @@ -198,6 +217,8 @@ export class DefaultExecutor extends BaseExecutor { } } switch (this.provider) { + case "perplexity-agent": + return this.config.baseUrl; case "openai": { // #5842: responses-only models (o1-pro / gpt-5.x-pro) 404 on // /v1/chat/completions ("only supported in v1/responses"). Route them to @@ -639,8 +660,7 @@ export class DefaultExecutor extends BaseExecutor { const record = body as Record; const rf = record.response_format as - | { type?: string; json_schema?: { schema?: unknown } } - | undefined; + { type?: string; json_schema?: { schema?: unknown } } | undefined; if (!rf) return body; // openai-compatible-* providers accept json_object natively — only the @@ -725,6 +745,9 @@ export class DefaultExecutor extends BaseExecutor { withDefaults = this.applyJsonSchemaFallback(withDefaults); withDefaults = this.defaultResponsesTextFormat(withDefaults); + if (this.provider === "perplexity-agent") { + withDefaults = defaultPerplexityAgentMaxOutputTokens(withDefaults); + } if (this.provider === "nvidia") { normalizeNvidiaToolCallIds(withDefaults); diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index 3c7ce5e7848d..34d8981ec800 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -391,7 +391,9 @@ export function openaiToOpenAIResponsesRequest( // Translate max_tokens / max_completion_tokens → max_output_tokens for Responses API. // The Responses API does not accept max_tokens or max_completion_tokens; it requires // max_output_tokens. max_completion_tokens takes priority as the newer Chat Completions field. - if (root.max_completion_tokens !== undefined) { + if (root.max_output_tokens !== undefined) { + result.max_output_tokens = root.max_output_tokens; + } else if (root.max_completion_tokens !== undefined) { result.max_output_tokens = root.max_completion_tokens; } else if (root.max_tokens !== undefined) { result.max_output_tokens = root.max_tokens; diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index 0257a4561674..7bcd163974e2 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -135,6 +135,21 @@ export const APIKEY_PROVIDERS_FRONTIER = { textIcon: "PP", website: "https://www.perplexity.ai", }, + "perplexity-agent": { + id: "perplexity-agent", + alias: "pplx-agent", + name: "Perplexity Agent", + icon: "search", + color: "#20808D", + textIcon: "PA", + website: "https://www.perplexity.ai", + authHint: + "Use your Perplexity API key. OmniRoute routes Agent API model IDs through Perplexity's Responses-compatible endpoint.", + apiHint: + "Use Agent API model IDs with the pplx-agent/ prefix, for example pplx-agent/openai/gpt-5.6-sol or pplx-agent/anthropic/claude-opus-4-5.", + passthroughModels: true, + serviceKinds: ["llm"], + }, cohere: { id: "cohere", alias: "cohere", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 732bec06ee99..d00eb4488a1b 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -4611,8 +4611,8 @@ } }, "url": { - "nonStream": "https://api.orcarouter.ai/v1", - "stream": "https://api.orcarouter.ai/v1" + "nonStream": "https://api.orcarouter.ai/v1/chat/completions", + "stream": "https://api.orcarouter.ai/v1/chat/completions" } }, "ovhcloud": { @@ -4661,6 +4661,29 @@ "stream": "https://api.perplexity.ai/chat/completions" } }, + "perplexity-agent": { + "format": "openai-responses", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://api.perplexity.ai/v1/responses", + "stream": "https://api.perplexity.ai/v1/responses" + } + }, "perplexity-web": { "format": "openai", "headers": { diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index c295631baee4..27ae13207e3e 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -284,6 +284,112 @@ test("chatCore sanitization preserves max_output_tokens for openai-responses tar ); }); +test("chatCore preserves Chat max_tokens as max_output_tokens for Perplexity Agent Anthropic models", async () => { + const { call, result } = await invokeChatCore({ + endpoint: "/v1/chat/completions", + provider: "perplexity-agent", + model: "anthropic/claude-opus-4-5", + body: { + model: "anthropic/claude-opus-4-5", + max_tokens: 32, + messages: [{ role: "user", content: "Reply with OK only." }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_pplx_agent", + object: "response", + status: "completed", + model: "anthropic/claude-opus-4-5", + output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "OK" }] }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + assert.equal(result.success, true); + assert.equal(call.url, "https://api.perplexity.ai/v1/responses"); + assert.equal(call.body.model, "anthropic/claude-opus-4-5"); + assert.equal(call.body.max_output_tokens, 32); + assert.equal("max_tokens" in call.body, false); + assert.equal("max_completion_tokens" in call.body, false); +}); + +test("chatCore defaults max_output_tokens for Perplexity Agent Anthropic chat requests", async () => { + const { call, result } = await invokeChatCore({ + endpoint: "/v1/chat/completions", + provider: "perplexity-agent", + model: "anthropic/claude-opus-4-5", + body: { + model: "anthropic/claude-opus-4-5", + messages: [{ role: "user", content: "hi" }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_pplx_agent_default_tokens", + object: "response", + status: "completed", + model: "anthropic/claude-opus-4-5", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Hello!" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + assert.equal(result.success, true); + assert.equal(call.url, "https://api.perplexity.ai/v1/responses"); + assert.equal(call.body.max_output_tokens, 4096); + assert.equal("max_tokens" in call.body, false); + assert.equal("max_completion_tokens" in call.body, false); +}); + +test("chatCore defaults max_output_tokens for Perplexity Agent Kimi chat requests", async () => { + const { call, result } = await invokeChatCore({ + endpoint: "/v1/chat/completions", + provider: "perplexity-agent", + model: "perplexity/kimi-k3", + body: { + model: "perplexity/kimi-k3", + messages: [{ role: "user", content: "hi" }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_pplx_agent_kimi_default_tokens", + object: "response", + status: "completed", + model: "perplexity/kimi-k3", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Hello!" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + assert.equal(result.success, true); + assert.equal(call.url, "https://api.perplexity.ai/v1/responses"); + assert.equal(call.body.max_output_tokens, 4096); + assert.equal("max_tokens" in call.body, false); + assert.equal("max_completion_tokens" in call.body, false); +}); + test("chatCore sanitization strips empty message names and filters empty tool names", async () => { // Note: `input` field is tested separately because its presence triggers // Responses format detection (PR #1002), which changes message handling. diff --git a/tests/unit/perplexity-agent-provider.test.ts b/tests/unit/perplexity-agent-provider.test.ts new file mode 100644 index 000000000000..33237a0d5e5c --- /dev/null +++ b/tests/unit/perplexity-agent-provider.test.ts @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-perplexity-agent-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "perplexity-agent-provider-test-secret"; + +const { REGISTRY, providerUsesAuthoritativeLiveCatalog } = + await import("../../open-sse/config/providerRegistry.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); +const { AI_PROVIDERS, APIKEY_PROVIDERS, getProviderByAlias, getProviderById } = + await import("../../src/shared/constants/providers.ts"); +const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); +const { replaceSyncedAvailableModelsForConnection } = await import("../../src/lib/db/models.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); + +const AGENT_RESPONSES_URL = "https://api.perplexity.ai/v1/responses"; +const AGENT_MODELS_URL = "https://api.perplexity.ai/v1/models"; +const AGENT_CONNECTION_ID = "perplexity-agent-live-catalog-test"; +const DOCUMENTED_AGENT_MODEL_IDS = [ + "anthropic/claude-fable-5", + "anthropic/claude-opus-5", + "anthropic/claude-opus-4-8", + "anthropic/claude-opus-4-7", + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-4-6", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-haiku-4-5", + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", + "openai/gpt-5.5", + "openai/gpt-5.4", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + "openai/gpt-5.2", + "openai/gpt-5.1", + "openai/gpt-5", + "openai/gpt-5-mini", + "google/gemini-3.1-pro-preview", + "google/gemini-3.1-flash-lite", + "google/gemini-3.5-flash", + "google/gemini-3.5-flash-lite", + "google/gemini-3.6-flash", + "google/gemini-3.7-flash", + "google/gemini-3-flash-preview", + "xai/grok-4.6", + "xai/grok-4.5", + "xai/grok-4.3", + "xai/grok-4.20-reasoning", + "xai/grok-4.20-non-reasoning", + "xai/grok-4.20-multi-agent", + "perplexity/deepseek-v4-flash-0731", + "perplexity/glm-5.2", + "perplexity/glm-5.3", + "perplexity/kimi-k3", + "perplexity/kimi-k2.7-code", + "perplexity/nemotron-3.5-lightning-30b-a3b", + "perplexity/nemotron-3-ultra-550b-a55b", + "perplexity/sonar", +] as const; + +test.after(() => { + dbCore.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } +}); + +async function seedOneItemAgentLiveCatalog() { + const now = new Date().toISOString(); + dbCore + .getDbInstance() + .prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run(AGENT_CONNECTION_ID, "perplexity-agent", 1, now, now); + + await replaceSyncedAvailableModelsForConnection("perplexity-agent", AGENT_CONNECTION_ID, [ + { + id: "openai/gpt-5.6-sol", + name: "GPT-5.6 Sol", + source: "imported", + }, + ]); +} + +test("perplexity-agent registry entry uses Responses format with passthrough models", () => { + const entry = REGISTRY["perplexity-agent"]; + + assert.ok(entry, "REGISTRY['perplexity-agent'] must be defined"); + assert.equal(entry.id, "perplexity-agent"); + assert.equal(entry.alias, "pplx-agent"); + assert.equal(entry.format, "openai-responses"); + assert.equal(entry.executor, "default"); + assert.equal(entry.baseUrl, AGENT_RESPONSES_URL); + assert.equal(entry.modelsUrl, AGENT_MODELS_URL); + assert.equal(entry.testKeyModelsUrl, AGENT_MODELS_URL); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.passthroughModels, true); + assert.equal(entry.liveCatalogAuthoritative, false); + assert.equal(providerUsesAuthoritativeLiveCatalog("perplexity-agent"), false); + assert.equal(providerUsesAuthoritativeLiveCatalog("pplx-agent"), false); +}); + +test("perplexity-agent is available from the canonical API-key provider catalog", () => { + const entry = APIKEY_PROVIDERS["perplexity-agent"]; + + assert.ok(entry, "APIKEY_PROVIDERS['perplexity-agent'] must be defined"); + assert.equal(entry.id, "perplexity-agent"); + assert.equal(entry.alias, "pplx-agent"); + assert.equal(entry.name, "Perplexity Agent"); + assert.equal(entry.icon, "search"); + assert.equal(entry.color, "#20808D"); + assert.equal(entry.textIcon, "PA"); + assert.equal(entry.website, "https://www.perplexity.ai"); + assert.equal(entry.passthroughModels, true); + assert.equal(AI_PROVIDERS["perplexity-agent"], entry); + assert.equal(getProviderById("perplexity-agent"), entry); + assert.equal(getProviderByAlias("pplx-agent"), entry); +}); + +test("perplexity-agent exposes minimal starter models without duplicating live discovery", () => { + const entry = REGISTRY["perplexity-agent"]; + const ids = new Set(entry.models.map((model) => model.id)); + + assert.deepEqual([...ids].sort(), ["openai/gpt-5.6-sol", "perplexity/kimi-k3"].sort()); + assert.equal(entry.models.length, 2); + assert.ok(entry.models.some((model) => model.id === "openai/gpt-5.6-sol")); + assert.ok(entry.models.some((model) => model.id === "perplexity/kimi-k3")); +}); + +test("pplx-agent prefix preserves raw slash-containing Agent API model IDs", async () => { + const info = await getModelInfo("pplx-agent/openai/gpt-5.6-sol"); + + assert.equal(info.provider, "perplexity-agent"); + assert.equal(info.model, "openai/gpt-5.6-sol"); +}); + +test("pplx-agent keeps unseen slash-containing Agent API IDs routable after live sync", async () => { + await seedOneItemAgentLiveCatalog(); + + const info = await getModelInfo("pplx-agent/future/labs/model-alpha"); + + assert.equal(info.provider, "perplexity-agent"); + assert.equal(info.model, "future/labs/model-alpha"); +}); + +test("perplexity-agent default executor dispatches to Perplexity Responses endpoint", () => { + const executor = new DefaultExecutor("perplexity-agent"); + + assert.equal(executor.buildUrl("openai/gpt-5.6-sol", false, 0, null), AGENT_RESPONSES_URL); +}); + +test("perplexity-agent defaults max_output_tokens when Agent requests omit token fields", () => { + const executor = new DefaultExecutor("perplexity-agent"); + const explicit = executor.transformRequest( + "anthropic/claude-opus-4-5", + { model: "anthropic/claude-opus-4-5", input: "hi", max_output_tokens: 32 }, + false, + null + ) as Record; + const defaulted = executor.transformRequest( + "anthropic/claude-opus-4-5", + { model: "anthropic/claude-opus-4-5", input: "hi" }, + false, + null + ) as Record; + const kimiAgent = executor.transformRequest( + "perplexity/kimi-k3", + { model: "perplexity/kimi-k3", input: "hi" }, + false, + null + ) as Record; + const futureAgent = executor.transformRequest( + "future-lab/model-alpha-1", + { model: "future-lab/model-alpha-1", input: "hi" }, + false, + null + ) as Record; + + assert.equal(explicit.max_output_tokens, 32); + assert.equal(defaulted.max_output_tokens, 4096); + assert.equal(kimiAgent.max_output_tokens, 4096); + assert.equal(futureAgent.max_output_tokens, 4096); +}); + +test("perplexity-agent model discovery accepts documented and future Agent API model IDs", async () => { + const { id } = (await createProviderConnection({ + provider: "perplexity-agent", + authType: "apikey", + name: "valid Perplexity Agent key", + apiKey: "pplx-valid-test-key", + isActive: true, + })) as { id?: unknown }; + assert.equal(typeof id, "string"); + + const originalFetch = globalThis.fetch; + const futureModelId = "future-lab/model-alpha-1"; + const upstreamModelIds = [...DOCUMENTED_AGENT_MODEL_IDS, futureModelId]; + let upstreamUrl: string | null = null; + let upstreamAuthorization: string | null = null; + globalThis.fetch = (async (input, init) => { + upstreamUrl = + input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url; + upstreamAuthorization = new Headers(init?.headers).get("authorization"); + + return new Response( + JSON.stringify({ + object: "list", + data: upstreamModelIds.map((modelId) => ({ + id: modelId, + object: "model", + owned_by: "perplexity-agent", + })), + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof globalThis.fetch; + + try { + const { GET } = await import("../../src/app/api/providers/[id]/models/route.ts"); + const response = await GET( + new Request(`http://localhost/api/providers/${id}/models?refresh=true`), + { params: { id } } + ); + const body = (await response.json()) as { models?: Array<{ id?: string }> }; + const modelIds = (body.models ?? []).map((model) => model.id); + + assert.equal(response.status, 200); + assert.equal(upstreamUrl, AGENT_MODELS_URL); + assert.equal(upstreamAuthorization, "Bearer pplx-valid-test-key"); + for (const modelId of upstreamModelIds) { + assert.ok(modelIds.includes(modelId), `expected discovered catalog to include ${modelId}`); + } + + const futureInfo = await getModelInfo(`pplx-agent/${futureModelId}`); + assert.equal(futureInfo.provider, "perplexity-agent"); + assert.equal(futureInfo.model, futureModelId); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("existing Perplexity Sonar provider remains unchanged", () => { + const entry = REGISTRY.perplexity; + + assert.ok(entry, "REGISTRY.perplexity must remain defined"); + assert.equal(entry.id, "perplexity"); + assert.equal(entry.alias, "pplx"); + assert.equal(entry.format, "openai"); + assert.equal(entry.baseUrl, "https://api.perplexity.ai/chat/completions"); + assert.equal(entry.testKeyModelsUrl, AGENT_MODELS_URL); +});