diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 288f6c2118c..0bd448ffa31 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -278,6 +278,7 @@ export const SECRET_STATE_KEYS = [ "sambaNovaApiKey", "zaiApiKey", "fireworksApiKey", + "perplexityApiKey", "vercelAiGatewayApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 43135577e16..27166929238 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -13,6 +13,7 @@ import { moonshotModels, openAiCodexModels, openAiNativeModels, + perplexityModels, qwenCodeModels, sambaNovaModels, vertexModels, @@ -122,6 +123,7 @@ export const providerNames = [ "minimax", "openai-codex", "openai-native", + "perplexity", "qwen-code", "roo", "sambanova", @@ -376,6 +378,10 @@ const fireworksSchema = apiModelIdProviderModelSchema.extend({ fireworksApiKey: z.string().optional(), }) +const perplexitySchema = apiModelIdProviderModelSchema.extend({ + perplexityApiKey: z.string().optional(), +}) + const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ qwenCodeOauthPath: z.string().optional(), }) @@ -425,6 +431,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })), zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })), fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })), + perplexitySchema.merge(z.object({ apiProvider: z.literal("perplexity") })), qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), rooSchema.merge(z.object({ apiProvider: z.literal("roo") })), vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), @@ -459,6 +466,7 @@ export const providerSettingsSchema = z.object({ ...sambaNovaSchema.shape, ...zaiSchema.shape, ...fireworksSchema.shape, + ...perplexitySchema.shape, ...qwenCodeSchema.shape, ...rooSchema.shape, ...vercelAiGatewaySchema.shape, @@ -535,6 +543,7 @@ export const modelIdKeysByProvider: Record = { sambanova: "apiModelId", zai: "apiModelId", fireworks: "apiModelId", + perplexity: "apiModelId", roo: "apiModelId", "vercel-ai-gateway": "vercelAiGatewayModelId", } @@ -596,6 +605,11 @@ export const MODELS_BY_PROVIDER: Record< label: "Fireworks", models: Object.keys(fireworksModels), }, + perplexity: { + id: "perplexity", + label: "Perplexity", + models: Object.keys(perplexityModels), + }, gemini: { id: "gemini", label: "Google Gemini", diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 6c180d5dda4..bf916b260ea 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -13,6 +13,7 @@ export * from "./openai.js" export * from "./openai-codex.js" export * from "./openai-codex-rate-limits.js" export * from "./openrouter.js" +export * from "./perplexity.js" export * from "./poe.js" export * from "./qwen-code.js" export * from "./requesty.js" @@ -37,6 +38,7 @@ import { mistralDefaultModelId } from "./mistral.js" import { moonshotDefaultModelId } from "./moonshot.js" import { openAiCodexDefaultModelId } from "./openai-codex.js" import { openRouterDefaultModelId } from "./openrouter.js" +import { perplexityDefaultModelId } from "./perplexity.js" import { poeDefaultModelId } from "./poe.js" import { qwenCodeDefaultModelId } from "./qwen-code.js" import { requestyDefaultModelId } from "./requesty.js" @@ -105,6 +107,8 @@ export function getProviderDefaultModelId( return sambaNovaDefaultModelId case "fireworks": return fireworksDefaultModelId + case "perplexity": + return perplexityDefaultModelId case "roo": return rooDefaultModelId case "qwen-code": diff --git a/packages/types/src/providers/perplexity.ts b/packages/types/src/providers/perplexity.ts new file mode 100644 index 00000000000..3e6e0007f4c --- /dev/null +++ b/packages/types/src/providers/perplexity.ts @@ -0,0 +1,50 @@ +import type { ModelInfo } from "../model.js" + +// Perplexity +// https://docs.perplexity.ai/docs/getting-started +// https://docs.perplexity.ai/guides/pricing +export type PerplexityModelId = keyof typeof perplexityModels + +export const perplexityDefaultModelId: PerplexityModelId = "sonar-pro" + +export const perplexityModels = { + sonar: { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 1.0, + outputPrice: 1.0, + description: + "Lightweight, cost-effective model with built-in web search grounding. Best for quick lookups and short answers.", + }, + "sonar-pro": { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 3.0, + outputPrice: 15.0, + description: + "Perplexity's flagship model with built-in web search grounding. Best for complex queries that benefit from up-to-date information.", + }, + "sonar-reasoning": { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 1.0, + outputPrice: 5.0, + description: "Reasoning model with chain-of-thought and built-in web search grounding.", + }, + "sonar-reasoning-pro": { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 2.0, + outputPrice: 8.0, + description: + "Reasoning model with extended chain-of-thought reasoning and built-in web search grounding for complex multi-step problems.", + }, +} as const satisfies Record diff --git a/src/api/index.ts b/src/api/index.ts index 1891113c03b..a1989ea9ddc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -30,6 +30,7 @@ import { SambaNovaHandler, ZAiHandler, FireworksHandler, + PerplexityHandler, RooHandler, VercelAiGatewayHandler, MiniMaxHandler, @@ -167,6 +168,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new ZAiHandler(options) case "fireworks": return new FireworksHandler(options) + case "perplexity": + return new PerplexityHandler(options) case "roo": // Never throw exceptions from provider constructors // The provider-proxy server will handle authentication and return appropriate error codes diff --git a/src/api/providers/__tests__/perplexity.spec.ts b/src/api/providers/__tests__/perplexity.spec.ts new file mode 100644 index 00000000000..505bf6cf000 --- /dev/null +++ b/src/api/providers/__tests__/perplexity.spec.ts @@ -0,0 +1,195 @@ +// npx vitest run api/providers/__tests__/perplexity.spec.ts + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { type PerplexityModelId, perplexityDefaultModelId, perplexityModels } from "@roo-code/types" + +import { PerplexityHandler, resolvePerplexityApiKey } from "../perplexity" + +const mockCreate = vi.fn() + +vi.mock("openai", () => ({ + default: vi.fn(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })), +})) + +describe("PerplexityHandler", () => { + let handler: PerplexityHandler + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + })) + handler = new PerplexityHandler({ perplexityApiKey: "test-key" }) + }) + + afterEach(() => { + vi.restoreAllMocks() + process.env = { ...originalEnv } + }) + + it("should use the correct Perplexity base URL", () => { + new PerplexityHandler({ perplexityApiKey: "test-perplexity-api-key" }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.perplexity.ai" })) + }) + + it("should use the provided API key from settings", () => { + const perplexityApiKey = "test-perplexity-api-key" + new PerplexityHandler({ perplexityApiKey }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: perplexityApiKey })) + }) + + it("should fall back to PERPLEXITY_API_KEY env var when no settings key is provided", () => { + delete process.env.PPLX_API_KEY + process.env.PERPLEXITY_API_KEY = "env-perplexity-key" + new PerplexityHandler({}) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "env-perplexity-key" })) + }) + + it("should fall back to PPLX_API_KEY env var as a secondary fallback", () => { + delete process.env.PERPLEXITY_API_KEY + process.env.PPLX_API_KEY = "pplx-fallback-key" + new PerplexityHandler({}) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "pplx-fallback-key" })) + }) + + it("should prefer explicit settings API key over env vars", () => { + process.env.PERPLEXITY_API_KEY = "env-key" + process.env.PPLX_API_KEY = "pplx-key" + new PerplexityHandler({ perplexityApiKey: "explicit-key" }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "explicit-key" })) + }) + + it("should throw when no API key is configured (settings or env vars)", () => { + delete process.env.PERPLEXITY_API_KEY + delete process.env.PPLX_API_KEY + expect(() => new PerplexityHandler({})).toThrow("API key is required") + }) + + it("resolvePerplexityApiKey should return undefined when nothing is set", () => { + delete process.env.PERPLEXITY_API_KEY + delete process.env.PPLX_API_KEY + expect(resolvePerplexityApiKey()).toBeUndefined() + expect(resolvePerplexityApiKey("")).toBeUndefined() + }) + + it("should return default sonar-pro model when no model is specified", () => { + const model = handler.getModel() + expect(model.id).toBe(perplexityDefaultModelId) + expect(model.id).toBe("sonar-pro") + expect(model.info).toEqual(expect.objectContaining(perplexityModels[perplexityDefaultModelId])) + }) + + it("should return sonar-reasoning-pro model when configured", () => { + const testModelId: PerplexityModelId = "sonar-reasoning-pro" + const handlerWithModel = new PerplexityHandler({ + apiModelId: testModelId, + perplexityApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual( + expect.objectContaining({ + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 2.0, + outputPrice: 8.0, + }), + ) + }) + + it("should fall back to default model when an unknown model id is provided", () => { + const handlerWithModel = new PerplexityHandler({ + apiModelId: "not-a-real-model", + perplexityApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(perplexityDefaultModelId) + }) + + it("should expose all four Sonar models with 128k context", () => { + const expectedIds: PerplexityModelId[] = ["sonar", "sonar-pro", "sonar-reasoning", "sonar-reasoning-pro"] + for (const id of expectedIds) { + expect(perplexityModels[id]).toBeDefined() + expect(perplexityModels[id].contextWindow).toBe(128_000) + } + }) + + it("createMessage should yield text content from stream", async () => { + const testContent = "Streamed content from Perplexity" + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: { content: testContent } }] }, + }) + .mockResolvedValueOnce({ done: true }), + }), + })) + + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + }) + + it("createMessage should pass the configured model id to the upstream client", async () => { + const modelId: PerplexityModelId = "sonar-reasoning" + const handlerWithModel = new PerplexityHandler({ + apiModelId: modelId, + perplexityApiKey: "test-key", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) + + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "hi" }] + const generator = handlerWithModel.createMessage("system", messages) + await generator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelId, + stream: true, + stream_options: { include_usage: true }, + messages: expect.arrayContaining([{ role: "system", content: "system" }]), + }), + undefined, + ) + }) + + it("createMessage should propagate upstream errors", async () => { + mockCreate.mockImplementationOnce(() => { + throw new Error("upstream 401") + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "hi" }]) + await expect(generator.next()).rejects.toThrow(/upstream 401/) + }) +}) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 41aff953d43..8d27de711cb 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -24,6 +24,7 @@ export { VsCodeLmHandler } from "./vscode-lm" export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" +export { PerplexityHandler } from "./perplexity" export { RooHandler } from "./roo" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { MiniMaxHandler } from "./minimax" diff --git a/src/api/providers/perplexity.ts b/src/api/providers/perplexity.ts new file mode 100644 index 00000000000..8767024e9e1 --- /dev/null +++ b/src/api/providers/perplexity.ts @@ -0,0 +1,33 @@ +import { type PerplexityModelId, perplexityDefaultModelId, perplexityModels } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" + +/** + * Resolves the Perplexity API key in priority order: + * 1. explicit settings value + * 2. PERPLEXITY_API_KEY env var + * 3. PPLX_API_KEY env var (fallback) + */ +export function resolvePerplexityApiKey(explicit?: string): string | undefined { + if (explicit && explicit.length > 0) { + return explicit + } + const fromEnv = process.env.PERPLEXITY_API_KEY ?? process.env.PPLX_API_KEY + return fromEnv && fromEnv.length > 0 ? fromEnv : undefined +} + +export class PerplexityHandler extends BaseOpenAiCompatibleProvider { + constructor(options: ApiHandlerOptions) { + super({ + ...options, + providerName: "Perplexity", + baseURL: "https://api.perplexity.ai", + apiKey: resolvePerplexityApiKey(options.perplexityApiKey), + defaultProviderModelId: perplexityDefaultModelId, + providerModels: perplexityModels, + defaultTemperature: 0, + }) + } +} diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 7246a90177a..f7534ee8267 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -63,6 +63,7 @@ export class ProfileValidator { case "xai": case "sambanova": case "fireworks": + case "perplexity": return profile.apiModelId case "litellm": return profile.litellmModelId diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index a6e4cc3f5f6..f386c4ce83e 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -92,6 +92,7 @@ import { XAI, ZAi, Fireworks, + Perplexity, VercelAiGateway, MiniMax, } from "./providers" @@ -708,6 +709,13 @@ const ApiOptions = ({ /> )} + {selectedProvider === "perplexity" && ( + + )} + {selectedProvider === "poe" && ( >> = { @@ -36,6 +37,7 @@ export const MODELS_BY_PROVIDER: Partial void +} + +export const Perplexity = ({ apiConfiguration, setApiConfigurationField }: PerplexityProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.perplexityApiKey && ( + + {t("settings:providers.getPerplexityApiKey")} + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 4a64ce9586b..91323bfec13 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -22,6 +22,7 @@ export { XAI } from "./XAI" export { ZAi } from "./ZAi" export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" +export { Perplexity } from "./Perplexity" export { VercelAiGateway } from "./VercelAiGateway" export { MiniMax } from "./MiniMax" export { Baseten } from "./Baseten" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 59f76862b45..ad4d3a4cc81 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -16,6 +16,7 @@ import { fireworksDefaultModelId, minimaxDefaultModelId, basetenDefaultModelId, + perplexityDefaultModelId, } from "@roo-code/types" import { MODELS_BY_PROVIDER } from "../constants" @@ -39,6 +40,7 @@ export const PROVIDER_SERVICE_CONFIG: Partial> = fireworks: fireworksDefaultModelId, minimax: minimaxDefaultModelId, baseten: basetenDefaultModelId, + perplexity: perplexityDefaultModelId, } export const getProviderServiceConfig = (provider: ProviderName): ProviderServiceConfig => { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 7192d9d4ee4..a21585b1cac 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -22,6 +22,7 @@ import { internationalZAiModels, mainlandZAiModels, fireworksModels, + perplexityModels, basetenModels, qwenCodeModels, litellmDefaultModelInfo, @@ -312,6 +313,11 @@ function getSelectedModel({ const info = fireworksModels[id as keyof typeof fireworksModels] return { id, info } } + case "perplexity": { + const id = apiConfiguration.apiModelId ?? defaultModelId + const info = perplexityModels[id as keyof typeof perplexityModels] + return { id, info } + } case "roo": { const id = getValidatedModelId(apiConfiguration.apiModelId, routerModels.roo, defaultModelId) const info = routerModels.roo?.[id] diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 8ec42367f14..ac8248d8ae0 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -445,6 +445,8 @@ "poeBaseUrl": "Poe Base URL", "fireworksApiKey": "Fireworks API Key", "getFireworksApiKey": "Get Fireworks API Key", + "perplexityApiKey": "Perplexity API Key", + "getPerplexityApiKey": "Get Perplexity API Key", "deepSeekApiKey": "DeepSeek API Key", "getDeepSeekApiKey": "Get DeepSeek API Key", "moonshotApiKey": "Moonshot API Key", diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index f506171acce..1ba4ef16a27 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -113,6 +113,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "perplexity": + if (!apiConfiguration.perplexityApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "qwen-code": if (!apiConfiguration.qwenCodeOauthPath) { return i18next.t("settings:validation.qwenCodeOauthPath")