From 340172e541ba5686a391f2502bb60927c8b5dcd6 Mon Sep 17 00:00:00 2001 From: james-pplx Date: Thu, 30 Apr 2026 22:29:55 +0000 Subject: [PATCH 1/3] Add Perplexity as a BYOK provider Wires up Perplexity as a Bring-Your-Own-Key (BYOK) language model provider in Copilot Chat. Perplexity exposes an OpenAI-compatible chat completions API at https://api.perplexity.ai, so the new provider extends AbstractOpenAICompatibleLMProvider, similar to the existing xAI and OpenRouter providers. - Adds src/extension/byok/vscode-node/perplexityProvider.ts with a curated list of Sonar models (sonar-pro, sonar, sonar-reasoning-pro, sonar-reasoning). sonar-pro is the default. - Registers the provider in byokContribution.ts. - Adds the "perplexity" vendor entry to languageModelChatProviders in package.json so the provider appears in the BYOK dropdown with an API key field. - Sends an X-Pplx-Integration: vscode-copilot/ header on outgoing requests via BYOKModelCapabilities.requestHeaders so Perplexity can attribute traffic to this integration. Docs: https://docs.perplexity.ai --- package.json | 17 +++ .../byok/vscode-node/byokContribution.ts | 2 + .../byok/vscode-node/perplexityProvider.ts | 116 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/extension/byok/vscode-node/perplexityProvider.ts diff --git a/package.json b/package.json index 89a00915e9..4f26bfa893 100644 --- a/package.json +++ b/package.json @@ -1797,6 +1797,23 @@ ] } }, + { + "vendor": "perplexity", + "displayName": "Perplexity", + "configuration": { + "properties": { + "apiKey": { + "type": "string", + "secret": true, + "description": "API key for Perplexity", + "title": "API Key" + } + }, + "required": [ + "apiKey" + ] + } + }, { "vendor": "openai", "displayName": "OpenAI", diff --git a/src/extension/byok/vscode-node/byokContribution.ts b/src/extension/byok/vscode-node/byokContribution.ts index 78f05af10b..fd4d9749b0 100644 --- a/src/extension/byok/vscode-node/byokContribution.ts +++ b/src/extension/byok/vscode-node/byokContribution.ts @@ -20,6 +20,7 @@ import { GeminiNativeBYOKLMProvider } from './geminiNativeProvider'; import { OllamaLMProvider } from './ollamaProvider'; import { OAIBYOKLMProvider } from './openAIProvider'; import { OpenRouterLMProvider } from './openRouterProvider'; +import { PerplexityLMProvider } from './perplexityProvider'; import { XAIBYOKLMProvider } from './xAIProvider'; export class BYOKContrib extends Disposable implements IExtensionContribution { @@ -59,6 +60,7 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { this._providers.set(XAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(XAIBYOKLMProvider, knownModels[XAIBYOKLMProvider.providerName], this._byokStorageService)); this._providers.set(OAIBYOKLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OAIBYOKLMProvider, knownModels[OAIBYOKLMProvider.providerName], this._byokStorageService)); this._providers.set(OpenRouterLMProvider.providerName.toLowerCase(), instantiationService.createInstance(OpenRouterLMProvider, this._byokStorageService)); + this._providers.set(PerplexityLMProvider.providerName.toLowerCase(), instantiationService.createInstance(PerplexityLMProvider, knownModels[PerplexityLMProvider.providerName], this._byokStorageService)); this._providers.set(AzureBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(AzureBYOKModelProvider, this._byokStorageService)); this._providers.set(CustomOAIBYOKModelProvider.providerName.toLowerCase(), instantiationService.createInstance(CustomOAIBYOKModelProvider, this._byokStorageService)); diff --git a/src/extension/byok/vscode-node/perplexityProvider.ts b/src/extension/byok/vscode-node/perplexityProvider.ts new file mode 100644 index 0000000000..19d53c83b1 --- /dev/null +++ b/src/extension/byok/vscode-node/perplexityProvider.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { packageJson } from '../../../platform/env/common/packagejson'; +import { BYOKKnownModels, BYOKModelCapabilities } from '../common/byokProvider'; +import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider'; +import { IBYOKStorageService } from './byokStorageService'; + +// https://docs.perplexity.ai/models/model-cards +// Perplexity exposes an OpenAI-compatible chat completions API at https://api.perplexity.ai +// but does not provide a stable `/models` discovery endpoint. We ship a curated list. +const PERPLEXITY_INTEGRATION_HEADER = 'X-Pplx-Integration'; + +const PERPLEXITY_KNOWN_MODELS: BYOKKnownModels = { + 'sonar-pro': { + name: 'Sonar Pro', + toolCalling: true, + vision: false, + maxInputTokens: 200000, + maxOutputTokens: 8000, + }, + 'sonar': { + name: 'Sonar', + toolCalling: true, + vision: false, + maxInputTokens: 128000, + maxOutputTokens: 8000, + }, + 'sonar-reasoning-pro': { + name: 'Sonar Reasoning Pro', + toolCalling: true, + vision: false, + maxInputTokens: 128000, + maxOutputTokens: 8000, + thinking: true, + }, + 'sonar-reasoning': { + name: 'Sonar Reasoning', + toolCalling: true, + vision: false, + maxInputTokens: 128000, + maxOutputTokens: 8000, + thinking: true, + }, +}; + +export class PerplexityLMProvider extends AbstractOpenAICompatibleLMProvider { + + public static readonly providerName = 'Perplexity'; + + constructor( + knownModels: BYOKKnownModels | undefined, + byokStorageService: IBYOKStorageService, + @IFetcherService fetcherService: IFetcherService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @IExperimentationService expService: IExperimentationService + ) { + super( + PerplexityLMProvider.providerName.toLowerCase(), + PerplexityLMProvider.providerName, + PerplexityLMProvider.mergeKnownModels(knownModels), + byokStorageService, + fetcherService, + logService, + instantiationService, + configurationService, + expService + ); + } + + private static mergeKnownModels(remote: BYOKKnownModels | undefined): BYOKKnownModels { + const integrationHeader = { + [PERPLEXITY_INTEGRATION_HEADER]: `vscode-copilot/${packageJson.version}`, + }; + const merged: BYOKKnownModels = {}; + for (const [id, caps] of Object.entries(PERPLEXITY_KNOWN_MODELS)) { + merged[id] = { ...caps, requestHeaders: { ...integrationHeader, ...(caps.requestHeaders ?? {}) } }; + } + if (remote) { + for (const [id, caps] of Object.entries(remote)) { + merged[id] = { ...caps, requestHeaders: { ...integrationHeader, ...(caps.requestHeaders ?? {}) } }; + } + } + return merged; + } + + protected getModelsBaseUrl(): string | undefined { + return 'https://api.perplexity.ai'; + } + + protected override resolveModelCapabilities(modelData: unknown): BYOKModelCapabilities | undefined { + const data = modelData as { id?: string; name?: string }; + if (!data?.id) { + return undefined; + } + // Sensible defaults for any model returned by /models that we don't already know about. + return { + name: data.name ?? data.id, + toolCalling: true, + vision: false, + maxInputTokens: 128000, + maxOutputTokens: 8000, + requestHeaders: { + [PERPLEXITY_INTEGRATION_HEADER]: `vscode-copilot/${packageJson.version}`, + }, + }; + } +} From 16b00ef04d2ade9f20da0a754b80c1020f0d6fe9 Mon Sep 17 00:00:00 2001 From: jliounis Date: Thu, 7 May 2026 12:33:04 +0000 Subject: [PATCH 2/3] refactor(byok/perplexity): use Agent API base + curated model list Replace Sonar chat-completions models with the Perplexity Agent API lineup (OpenAI, Anthropic, Google) at the OpenAI-Responses-compatible base URL https://api.perplexity.ai/v1. Also override getAllModels to return the curated list directly (Perplexity does not expose a stable /models discovery endpoint), and flip the requestHeaders merge order so X-Pplx-Integration is never overridden by per-model requestHeaders. --- package.json | 2 +- .../byok/vscode-node/perplexityProvider.ts | 76 ++++++++++++------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 4f26bfa893..6060b655e2 100644 --- a/package.json +++ b/package.json @@ -1805,7 +1805,7 @@ "apiKey": { "type": "string", "secret": true, - "description": "API key for Perplexity", + "description": "API key for the Perplexity Agent API (OpenAI-Responses-compatible)", "title": "API Key" } }, diff --git a/src/extension/byok/vscode-node/perplexityProvider.ts b/src/extension/byok/vscode-node/perplexityProvider.ts index 19d53c83b1..ddbcb33817 100644 --- a/src/extension/byok/vscode-node/perplexityProvider.ts +++ b/src/extension/byok/vscode-node/perplexityProvider.ts @@ -8,46 +8,54 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { packageJson } from '../../../platform/env/common/packagejson'; -import { BYOKKnownModels, BYOKModelCapabilities } from '../common/byokProvider'; -import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider'; +import { BYOKKnownModels, BYOKModelCapabilities, byokKnownModelsToAPIInfo } from '../common/byokProvider'; +import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider'; import { IBYOKStorageService } from './byokStorageService'; -// https://docs.perplexity.ai/models/model-cards -// Perplexity exposes an OpenAI-compatible chat completions API at https://api.perplexity.ai -// but does not provide a stable `/models` discovery endpoint. We ship a curated list. +// Perplexity Agent API: https://docs.perplexity.ai/docs/agent-api +// OpenAI-Responses-compatible base URL: https://api.perplexity.ai/v1 +// Exposes third-party models (OpenAI, Anthropic, Google, etc.) plus presets +// (e.g. pro-search) under one API key. Does not provide a stable `/models` +// discovery endpoint, so we ship a curated list and override `getAllModels`. const PERPLEXITY_INTEGRATION_HEADER = 'X-Pplx-Integration'; const PERPLEXITY_KNOWN_MODELS: BYOKKnownModels = { - 'sonar-pro': { - name: 'Sonar Pro', + 'openai/gpt-5.4': { + name: 'GPT-5.4 (via Perplexity Agent API)', toolCalling: true, - vision: false, + vision: true, maxInputTokens: 200000, - maxOutputTokens: 8000, + maxOutputTokens: 16000, }, - 'sonar': { - name: 'Sonar', + 'openai/gpt-5.2': { + name: 'GPT-5.2 (via Perplexity Agent API)', toolCalling: true, - vision: false, - maxInputTokens: 128000, - maxOutputTokens: 8000, + vision: true, + maxInputTokens: 200000, + maxOutputTokens: 16000, }, - 'sonar-reasoning-pro': { - name: 'Sonar Reasoning Pro', + 'anthropic/claude-sonnet-4-6': { + name: 'Claude Sonnet 4.6 (via Perplexity Agent API)', toolCalling: true, - vision: false, - maxInputTokens: 128000, - maxOutputTokens: 8000, - thinking: true, + vision: true, + maxInputTokens: 200000, + maxOutputTokens: 16000, }, - 'sonar-reasoning': { - name: 'Sonar Reasoning', + 'anthropic/claude-opus-4-7': { + name: 'Claude Opus 4.7 (via Perplexity Agent API)', toolCalling: true, - vision: false, - maxInputTokens: 128000, - maxOutputTokens: 8000, + vision: true, + maxInputTokens: 200000, + maxOutputTokens: 32000, thinking: true, }, + 'google/gemini-3-1-pro': { + name: 'Gemini 3.1 Pro (via Perplexity Agent API)', + toolCalling: true, + vision: true, + maxInputTokens: 1000000, + maxOutputTokens: 16000, + }, }; export class PerplexityLMProvider extends AbstractOpenAICompatibleLMProvider { @@ -82,18 +90,30 @@ export class PerplexityLMProvider extends AbstractOpenAICompatibleLMProvider { }; const merged: BYOKKnownModels = {}; for (const [id, caps] of Object.entries(PERPLEXITY_KNOWN_MODELS)) { - merged[id] = { ...caps, requestHeaders: { ...integrationHeader, ...(caps.requestHeaders ?? {}) } }; + merged[id] = { ...caps, requestHeaders: { ...(caps.requestHeaders ?? {}), ...integrationHeader } }; } if (remote) { for (const [id, caps] of Object.entries(remote)) { - merged[id] = { ...caps, requestHeaders: { ...integrationHeader, ...(caps.requestHeaders ?? {}) } }; + merged[id] = { ...caps, requestHeaders: { ...(caps.requestHeaders ?? {}), ...integrationHeader } }; } } return merged; } protected getModelsBaseUrl(): string | undefined { - return 'https://api.perplexity.ai'; + // Agent API base URL. The Agent API is OpenAI-Responses-compatible at + // /v1/responses (alias) and /v1/agent (primary). It exposes third-party + // models from OpenAI, Anthropic, Google, etc., plus presets like pro-search. + return 'https://api.perplexity.ai/v1'; + } + + protected override async getAllModels(silent: boolean, apiKey: string | undefined, configuration: LanguageModelChatConfiguration | undefined): Promise[]> { + const baseUrl = this.getModelsBaseUrl(); + const merged = this._knownModels ?? {}; + return byokKnownModelsToAPIInfo(this._name, merged).map(model => ({ + ...model, + url: baseUrl ?? 'https://api.perplexity.ai/v1', + })); } protected override resolveModelCapabilities(modelData: unknown): BYOKModelCapabilities | undefined { From 61bc734812e2514c5a3d348b7ce94a3dae268ed2 Mon Sep 17 00:00:00 2001 From: jliounis Date: Thu, 7 May 2026 12:33:09 +0000 Subject: [PATCH 3/3] test(byok/perplexity): add unit tests for curated list + integration header Three cases: - getAllModels returns the curated Agent API list with the correct base URL and asserts no sonar references remain. - every model carries the X-Pplx-Integration header in merged capabilities. - per-model requestHeaders cannot override the integration header. --- .../test/perplexityProvider.spec.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/extension/byok/vscode-node/test/perplexityProvider.spec.ts diff --git a/src/extension/byok/vscode-node/test/perplexityProvider.spec.ts b/src/extension/byok/vscode-node/test/perplexityProvider.spec.ts new file mode 100644 index 0000000000..6b74befbe6 --- /dev/null +++ b/src/extension/byok/vscode-node/test/perplexityProvider.spec.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import { PerplexityLMProvider } from '../perplexityProvider'; + +function createProvider(knownModels?: Record) { + const fetch = vi.fn(async (url: string) => { + throw new Error(`Unexpected fetch in test: ${url}`); + }); + + const logService = { + _serviceBrand: undefined, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + show: vi.fn(), + createSubLogger: vi.fn(), + withExtraTarget: vi.fn(), + }; + logService.createSubLogger.mockReturnValue(logService); + logService.withExtraTarget.mockReturnValue(logService); + + const storage = { + getAPIKey: vi.fn().mockResolvedValue(undefined), + storeAPIKey: vi.fn().mockResolvedValue(undefined), + deleteAPIKey: vi.fn().mockResolvedValue(undefined), + getStoredModelConfigs: vi.fn().mockResolvedValue({}), + saveModelConfig: vi.fn().mockResolvedValue(undefined), + removeModelConfig: vi.fn().mockResolvedValue(undefined), + }; + + const provider = new PerplexityLMProvider( + knownModels as any, + storage as any, + { fetch } as any, + logService as any, + { createInstance: vi.fn().mockReturnValue({}) } as any, + { + isConfigured: vi.fn().mockReturnValue(false), + getConfig: vi.fn(), + setConfig: vi.fn(), + } as any, + {} as any + ); + + return { provider, fetch, storage, logService }; +} + +describe('PerplexityLMProvider', () => { + it('getAllModels returns the curated Agent API model list', async () => { + const { provider, fetch } = createProvider(); + + const models = await (provider as any).getAllModels(true, 'test-api-key', undefined); + + expect(models.length).toBe(5); + + const expectedIds = [ + 'openai/gpt-5.4', + 'openai/gpt-5.2', + 'anthropic/claude-sonnet-4-6', + 'anthropic/claude-opus-4-7', + 'google/gemini-3-1-pro', + ]; + const ids = models.map((m: any) => m.id); + expect(ids.sort()).toEqual(expectedIds.sort()); + + // No sonar references at all in the curated list. + expect(models.some((m: any) => m.id.includes('sonar'))).toBe(false); + expect(models.some((m: any) => (m.name ?? '').toLowerCase().includes('sonar'))).toBe(false); + + // Each model points at the Agent API base URL. + for (const model of models) { + expect(model.url).toBe('https://api.perplexity.ai/v1'); + } + + // /models endpoint must never be queried — getAllModels is overridden. + expect(fetch).not.toHaveBeenCalled(); + }); + + it('every model includes X-Pplx-Integration header in merged capabilities', async () => { + const { provider } = createProvider(); + + const knownModels = (provider as any)._knownModels as Record }>; + expect(knownModels).toBeDefined(); + const ids = Object.keys(knownModels); + expect(ids.length).toBe(5); + + for (const id of ids) { + const headers = knownModels[id].requestHeaders; + expect(headers).toBeDefined(); + expect(headers!['X-Pplx-Integration']).toMatch(/^vscode-copilot\//); + } + }); + + it('integration header is not overridden by per-model requestHeaders', () => { + const { provider } = createProvider({ + 'openai/gpt-5.4': { + name: 'GPT-5.4 (custom)', + toolCalling: true, + vision: true, + maxInputTokens: 200000, + maxOutputTokens: 16000, + requestHeaders: { + 'X-Pplx-Integration': 'malicious-override', + 'X-Custom-Header': 'kept', + }, + }, + }); + + const knownModels = (provider as any)._knownModels as Record }>; + const headers = knownModels['openai/gpt-5.4'].requestHeaders!; + + // The provider's integration header always wins. + expect(headers['X-Pplx-Integration']).toMatch(/^vscode-copilot\//); + expect(headers['X-Pplx-Integration']).not.toBe('malicious-override'); + + // Other custom headers should be preserved. + expect(headers['X-Custom-Header']).toBe('kept'); + }); +});