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

Commit ccdf9be

Browse files
author
james-pplx
committed
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 ad25634 commit ccdf9be

17 files changed

Lines changed: 380 additions & 0 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ export const SECRET_STATE_KEYS = [
278278
"sambaNovaApiKey",
279279
"zaiApiKey",
280280
"fireworksApiKey",
281+
"perplexityApiKey",
281282
"vercelAiGatewayApiKey",
282283
"basetenApiKey",
283284
] 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,
@@ -122,6 +123,7 @@ export const providerNames = [
122123
"minimax",
123124
"openai-codex",
124125
"openai-native",
126+
"perplexity",
125127
"qwen-code",
126128
"roo",
127129
"sambanova",
@@ -376,6 +378,10 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({
376378
fireworksApiKey: z.string().optional(),
377379
})
378380

381+
const perplexitySchema = apiModelIdProviderModelSchema.extend({
382+
perplexityApiKey: z.string().optional(),
383+
})
384+
379385
const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
380386
qwenCodeOauthPath: z.string().optional(),
381387
})
@@ -425,6 +431,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
425431
sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })),
426432
zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })),
427433
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
434+
perplexitySchema.merge(z.object({ apiProvider: z.literal("perplexity") })),
428435
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
429436
rooSchema.merge(z.object({ apiProvider: z.literal("roo") })),
430437
vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })),
@@ -459,6 +466,7 @@ export const providerSettingsSchema = z.object({
459466
...sambaNovaSchema.shape,
460467
...zaiSchema.shape,
461468
...fireworksSchema.shape,
469+
...perplexitySchema.shape,
462470
...qwenCodeSchema.shape,
463471
...rooSchema.shape,
464472
...vercelAiGatewaySchema.shape,
@@ -535,6 +543,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
535543
sambanova: "apiModelId",
536544
zai: "apiModelId",
537545
fireworks: "apiModelId",
546+
perplexity: "apiModelId",
538547
roo: "apiModelId",
539548
"vercel-ai-gateway": "vercelAiGatewayModelId",
540549
}
@@ -596,6 +605,11 @@ export const MODELS_BY_PROVIDER: Record<
596605
label: "Fireworks",
597606
models: Object.keys(fireworksModels),
598607
},
608+
perplexity: {
609+
id: "perplexity",
610+
label: "Perplexity",
611+
models: Object.keys(perplexityModels),
612+
},
599613
gemini: {
600614
id: "gemini",
601615
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"
@@ -37,6 +38,7 @@ import { mistralDefaultModelId } from "./mistral.js"
3738
import { moonshotDefaultModelId } from "./moonshot.js"
3839
import { openAiCodexDefaultModelId } from "./openai-codex.js"
3940
import { openRouterDefaultModelId } from "./openrouter.js"
41+
import { perplexityDefaultModelId } from "./perplexity.js"
4042
import { poeDefaultModelId } from "./poe.js"
4143
import { qwenCodeDefaultModelId } from "./qwen-code.js"
4244
import { requestyDefaultModelId } from "./requesty.js"
@@ -105,6 +107,8 @@ export function getProviderDefaultModelId(
105107
return sambaNovaDefaultModelId
106108
case "fireworks":
107109
return fireworksDefaultModelId
110+
case "perplexity":
111+
return perplexityDefaultModelId
108112
case "roo":
109113
return rooDefaultModelId
110114
case "qwen-code":
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
RooHandler,
3435
VercelAiGatewayHandler,
3536
MiniMaxHandler,
@@ -167,6 +168,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
167168
return new ZAiHandler(options)
168169
case "fireworks":
169170
return new FireworksHandler(options)
171+
case "perplexity":
172+
return new PerplexityHandler(options)
170173
case "roo":
171174
// Never throw exceptions from provider constructors
172175
// The provider-proxy server will handle authentication and return appropriate error codes
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// npx vitest run api/providers/__tests__/perplexity.spec.ts
2+
3+
import { Anthropic } from "@anthropic-ai/sdk"
4+
import OpenAI from "openai"
5+
6+
import { type PerplexityModelId, perplexityDefaultModelId, perplexityModels } from "@roo-code/types"
7+
8+
import { PerplexityHandler, resolvePerplexityApiKey } from "../perplexity"
9+
10+
const mockCreate = vi.fn()
11+
12+
vi.mock("openai", () => ({
13+
default: vi.fn(() => ({
14+
chat: {
15+
completions: {
16+
create: mockCreate,
17+
},
18+
},
19+
})),
20+
}))
21+
22+
describe("PerplexityHandler", () => {
23+
let handler: PerplexityHandler
24+
const originalEnv = { ...process.env }
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
mockCreate.mockImplementation(async () => ({
29+
[Symbol.asyncIterator]: async function* () {
30+
yield {
31+
choices: [{ delta: { content: "Test response" }, index: 0 }],
32+
usage: null,
33+
}
34+
yield {
35+
choices: [{ delta: {}, index: 0 }],
36+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
37+
}
38+
},
39+
}))
40+
handler = new PerplexityHandler({ perplexityApiKey: "test-key" })
41+
})
42+
43+
afterEach(() => {
44+
vi.restoreAllMocks()
45+
process.env = { ...originalEnv }
46+
})
47+
48+
it("should use the correct Perplexity base URL", () => {
49+
new PerplexityHandler({ perplexityApiKey: "test-perplexity-api-key" })
50+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.perplexity.ai" }))
51+
})
52+
53+
it("should use the provided API key from settings", () => {
54+
const perplexityApiKey = "test-perplexity-api-key"
55+
new PerplexityHandler({ perplexityApiKey })
56+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: perplexityApiKey }))
57+
})
58+
59+
it("should fall back to PERPLEXITY_API_KEY env var when no settings key is provided", () => {
60+
delete process.env.PPLX_API_KEY
61+
process.env.PERPLEXITY_API_KEY = "env-perplexity-key"
62+
new PerplexityHandler({})
63+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "env-perplexity-key" }))
64+
})
65+
66+
it("should fall back to PPLX_API_KEY env var as a secondary fallback", () => {
67+
delete process.env.PERPLEXITY_API_KEY
68+
process.env.PPLX_API_KEY = "pplx-fallback-key"
69+
new PerplexityHandler({})
70+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "pplx-fallback-key" }))
71+
})
72+
73+
it("should prefer explicit settings API key over env vars", () => {
74+
process.env.PERPLEXITY_API_KEY = "env-key"
75+
process.env.PPLX_API_KEY = "pplx-key"
76+
new PerplexityHandler({ perplexityApiKey: "explicit-key" })
77+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "explicit-key" }))
78+
})
79+
80+
it("should throw when no API key is configured (settings or env vars)", () => {
81+
delete process.env.PERPLEXITY_API_KEY
82+
delete process.env.PPLX_API_KEY
83+
expect(() => new PerplexityHandler({})).toThrow("API key is required")
84+
})
85+
86+
it("resolvePerplexityApiKey should return undefined when nothing is set", () => {
87+
delete process.env.PERPLEXITY_API_KEY
88+
delete process.env.PPLX_API_KEY
89+
expect(resolvePerplexityApiKey()).toBeUndefined()
90+
expect(resolvePerplexityApiKey("")).toBeUndefined()
91+
})
92+
93+
it("should return default sonar-pro model when no model is specified", () => {
94+
const model = handler.getModel()
95+
expect(model.id).toBe(perplexityDefaultModelId)
96+
expect(model.id).toBe("sonar-pro")
97+
expect(model.info).toEqual(expect.objectContaining(perplexityModels[perplexityDefaultModelId]))
98+
})
99+
100+
it("should return sonar-reasoning-pro model when configured", () => {
101+
const testModelId: PerplexityModelId = "sonar-reasoning-pro"
102+
const handlerWithModel = new PerplexityHandler({
103+
apiModelId: testModelId,
104+
perplexityApiKey: "test-key",
105+
})
106+
const model = handlerWithModel.getModel()
107+
expect(model.id).toBe(testModelId)
108+
expect(model.info).toEqual(
109+
expect.objectContaining({
110+
maxTokens: 8192,
111+
contextWindow: 128_000,
112+
supportsImages: false,
113+
supportsPromptCache: false,
114+
inputPrice: 2.0,
115+
outputPrice: 8.0,
116+
}),
117+
)
118+
})
119+
120+
it("should fall back to default model when an unknown model id is provided", () => {
121+
const handlerWithModel = new PerplexityHandler({
122+
apiModelId: "not-a-real-model",
123+
perplexityApiKey: "test-key",
124+
})
125+
const model = handlerWithModel.getModel()
126+
expect(model.id).toBe(perplexityDefaultModelId)
127+
})
128+
129+
it("should expose all four Sonar models with 128k context", () => {
130+
const expectedIds: PerplexityModelId[] = ["sonar", "sonar-pro", "sonar-reasoning", "sonar-reasoning-pro"]
131+
for (const id of expectedIds) {
132+
expect(perplexityModels[id]).toBeDefined()
133+
expect(perplexityModels[id].contextWindow).toBe(128_000)
134+
}
135+
})
136+
137+
it("createMessage should yield text content from stream", async () => {
138+
const testContent = "Streamed content from Perplexity"
139+
mockCreate.mockImplementationOnce(() => ({
140+
[Symbol.asyncIterator]: () => ({
141+
next: vi
142+
.fn()
143+
.mockResolvedValueOnce({
144+
done: false,
145+
value: { choices: [{ delta: { content: testContent } }] },
146+
})
147+
.mockResolvedValueOnce({ done: true }),
148+
}),
149+
}))
150+
151+
const stream = handler.createMessage("system prompt", [])
152+
const firstChunk = await stream.next()
153+
expect(firstChunk.done).toBe(false)
154+
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
155+
})
156+
157+
it("createMessage should pass the configured model id to the upstream client", async () => {
158+
const modelId: PerplexityModelId = "sonar-reasoning"
159+
const handlerWithModel = new PerplexityHandler({
160+
apiModelId: modelId,
161+
perplexityApiKey: "test-key",
162+
})
163+
164+
mockCreate.mockImplementationOnce(() => ({
165+
[Symbol.asyncIterator]: () => ({
166+
async next() {
167+
return { done: true }
168+
},
169+
}),
170+
}))
171+
172+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "hi" }]
173+
const generator = handlerWithModel.createMessage("system", messages)
174+
await generator.next()
175+
176+
expect(mockCreate).toHaveBeenCalledWith(
177+
expect.objectContaining({
178+
model: modelId,
179+
stream: true,
180+
stream_options: { include_usage: true },
181+
messages: expect.arrayContaining([{ role: "system", content: "system" }]),
182+
}),
183+
undefined,
184+
)
185+
})
186+
187+
it("createMessage should propagate upstream errors", async () => {
188+
mockCreate.mockImplementationOnce(() => {
189+
throw new Error("upstream 401")
190+
})
191+
192+
const generator = handler.createMessage("system", [{ role: "user", content: "hi" }])
193+
await expect(generator.next()).rejects.toThrow(/upstream 401/)
194+
})
195+
})

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export { VsCodeLmHandler } from "./vscode-lm"
2424
export { XAIHandler } from "./xai"
2525
export { ZAiHandler } from "./zai"
2626
export { FireworksHandler } from "./fireworks"
27+
export { PerplexityHandler } from "./perplexity"
2728
export { RooHandler } from "./roo"
2829
export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
2930
export { MiniMaxHandler } from "./minimax"

0 commit comments

Comments
 (0)