Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 16e3760

Browse files
james-pplxPSI Bot
authored andcommitted
feat: add Perplexity as a first-class API provider
Introduce a new Perplexity provider that targets Perplexity's OpenAI-compatible chat-completions endpoint at https://api.perplexity.ai. Models exposed: sonar, sonar-pro, sonar-reasoning, sonar-reasoning-pro (all 128k context). API key resolution order: 1. perplexityApiKey from settings 2. PERPLEXITY_API_KEY env var 3. PPLX_API_KEY env var (fallback) Wired into the provider registry (packages/types), api factory, profile validation, webview settings UI, model picker, validation, and English locale strings. Tests cover construction, base URL/auth, env-var fallbacks, model selection, streaming, and error propagation.
1 parent b867ec9 commit 16e3760

19 files changed

Lines changed: 464 additions & 8 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const SECRET_STATE_KEYS = [
275275
"sambaNovaApiKey",
276276
"zaiApiKey",
277277
"fireworksApiKey",
278+
"perplexityApiKey",
278279
"vercelAiGatewayApiKey",
279280
"basetenApiKey",
280281
] as const

packages/types/src/provider-settings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
moonshotModels,
1414
openAiCodexModels,
1515
openAiNativeModels,
16+
perplexityModels,
1617
qwenCodeModels,
1718
sambaNovaModels,
1819
vertexModels,
@@ -114,6 +115,7 @@ export const providerNames = [
114115
"minimax",
115116
"openai-codex",
116117
"openai-native",
118+
"perplexity",
117119
"qwen-code",
118120
"sambanova",
119121
"vertex",
@@ -368,6 +370,10 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({
368370
fireworksApiKey: z.string().optional(),
369371
})
370372

373+
const perplexitySchema = apiModelIdProviderModelSchema.extend({
374+
perplexityApiKey: z.string().optional(),
375+
})
376+
371377
const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
372378
qwenCodeOauthPath: z.string().optional(),
373379
})
@@ -412,6 +418,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
412418
sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })),
413419
zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })),
414420
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
421+
perplexitySchema.merge(z.object({ apiProvider: z.literal("perplexity") })),
415422
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
416423
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
417424
defaultSchema,
@@ -445,6 +452,7 @@ export const providerSettingsSchema = z.object({
445452
...sambaNovaSchema.shape,
446453
...zaiSchema.shape,
447454
...fireworksSchema.shape,
455+
...perplexitySchema.shape,
448456
...qwenCodeSchema.shape,
449457
...vercelAiGatewaySchema.shape,
450458
...codebaseIndexProviderSchema.shape,
@@ -520,6 +528,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
520528
sambanova: "apiModelId",
521529
zai: "apiModelId",
522530
fireworks: "apiModelId",
531+
perplexity: "apiModelId",
523532
"vercel-ai-gateway": "vercelAiGatewayModelId",
524533
}
525534

@@ -575,6 +584,11 @@ export const MODELS_BY_PROVIDER: Record<
575584
label: "Fireworks",
576585
models: Object.keys(fireworksModels),
577586
},
587+
perplexity: {
588+
id: "perplexity",
589+
label: "Perplexity",
590+
models: Object.keys(perplexityModels),
591+
},
578592
gemini: {
579593
id: "gemini",
580594
label: "Google Gemini",

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from "./openai.js"
1313
export * from "./openai-codex.js"
1414
export * from "./openai-codex-rate-limits.js"
1515
export * from "./openrouter.js"
16+
export * from "./perplexity.js"
1617
export * from "./poe.js"
1718
export * from "./qwen-code.js"
1819
export * from "./requesty.js"
@@ -36,6 +37,7 @@ import { mistralDefaultModelId } from "./mistral.js"
3637
import { moonshotDefaultModelId } from "./moonshot.js"
3738
import { openAiCodexDefaultModelId } from "./openai-codex.js"
3839
import { openRouterDefaultModelId } from "./openrouter.js"
40+
import { perplexityDefaultModelId } from "./perplexity.js"
3941
import { poeDefaultModelId } from "./poe.js"
4042
import { qwenCodeDefaultModelId } from "./qwen-code.js"
4143
import { requestyDefaultModelId } from "./requesty.js"
@@ -103,6 +105,8 @@ export function getProviderDefaultModelId(
103105
return sambaNovaDefaultModelId
104106
case "fireworks":
105107
return fireworksDefaultModelId
108+
case "perplexity":
109+
return perplexityDefaultModelId
106110
case "qwen-code":
107111
return qwenCodeDefaultModelId
108112
case "poe":
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
// Perplexity
4+
// https://docs.perplexity.ai/docs/getting-started
5+
// https://docs.perplexity.ai/guides/pricing
6+
export type PerplexityModelId = keyof typeof perplexityModels
7+
8+
export const perplexityDefaultModelId: PerplexityModelId = "sonar-pro"
9+
10+
export const perplexityModels = {
11+
sonar: {
12+
maxTokens: 8192,
13+
contextWindow: 128_000,
14+
supportsImages: false,
15+
supportsPromptCache: false,
16+
inputPrice: 1.0,
17+
outputPrice: 1.0,
18+
description:
19+
"Lightweight, cost-effective model with built-in web search grounding. Best for quick lookups and short answers.",
20+
},
21+
"sonar-pro": {
22+
maxTokens: 8192,
23+
contextWindow: 128_000,
24+
supportsImages: false,
25+
supportsPromptCache: false,
26+
inputPrice: 3.0,
27+
outputPrice: 15.0,
28+
description:
29+
"Perplexity's flagship model with built-in web search grounding. Best for complex queries that benefit from up-to-date information.",
30+
},
31+
"sonar-reasoning": {
32+
maxTokens: 8192,
33+
contextWindow: 128_000,
34+
supportsImages: false,
35+
supportsPromptCache: false,
36+
inputPrice: 1.0,
37+
outputPrice: 5.0,
38+
description: "Reasoning model with chain-of-thought and built-in web search grounding.",
39+
},
40+
"sonar-reasoning-pro": {
41+
maxTokens: 8192,
42+
contextWindow: 128_000,
43+
supportsImages: false,
44+
supportsPromptCache: false,
45+
inputPrice: 2.0,
46+
outputPrice: 8.0,
47+
description:
48+
"Reasoning model with extended chain-of-thought reasoning and built-in web search grounding for complex multi-step problems.",
49+
},
50+
} as const satisfies Record<string, ModelInfo>

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
SambaNovaHandler,
3131
ZAiHandler,
3232
FireworksHandler,
33+
PerplexityHandler,
3334
VercelAiGatewayHandler,
3435
MiniMaxHandler,
3536
BasetenHandler,
@@ -169,6 +170,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
169170
return new ZAiHandler(options)
170171
case "fireworks":
171172
return new FireworksHandler(options)
173+
case "perplexity":
174+
return new PerplexityHandler(options)
172175
case "vercel-ai-gateway":
173176
return new VercelAiGatewayHandler(options)
174177
case "minimax":

src/api/providers/__tests__/base-openai-compatible-provider.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,42 @@ describe("BaseOpenAiCompatibleProvider", () => {
326326
// Should yield reasoning with spaces (only pure whitespace is filtered)
327327
expect(chunks).toEqual([{ type: "reasoning", text: " content with spaces " }])
328328
})
329+
330+
it("should yield reasoning_content before content when both are present in a delta", async () => {
331+
mockCreate.mockImplementationOnce(() => {
332+
return {
333+
[Symbol.asyncIterator]: () => ({
334+
next: vi
335+
.fn()
336+
.mockResolvedValueOnce({
337+
done: false,
338+
value: {
339+
choices: [
340+
{
341+
delta: {
342+
reasoning_content: "Thinking first",
343+
content: "Final answer",
344+
},
345+
},
346+
],
347+
},
348+
})
349+
.mockResolvedValueOnce({ done: true }),
350+
}),
351+
}
352+
})
353+
354+
const stream = handler.createMessage("system prompt", [])
355+
const chunks = []
356+
for await (const chunk of stream) {
357+
chunks.push(chunk)
358+
}
359+
360+
expect(chunks).toEqual([
361+
{ type: "reasoning", text: "Thinking first" },
362+
{ type: "text", text: "Final answer" },
363+
])
364+
})
329365
})
330366

331367
describe("Basic functionality", () => {

0 commit comments

Comments
 (0)