diff --git a/.changeset/forty-readers-clap.md b/.changeset/forty-readers-clap.md new file mode 100644 index 000000000000..6b88a173b9de --- /dev/null +++ b/.changeset/forty-readers-clap.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/perplexity': major +--- + +feat (provider/perplexity): rewrite provider and support sources diff --git a/.changeset/rude-actors-fly.md b/.changeset/rude-actors-fly.md new file mode 100644 index 000000000000..3eaf25cba903 --- /dev/null +++ b/.changeset/rude-actors-fly.md @@ -0,0 +1,6 @@ +--- +'@ai-sdk/provider-utils': patch +'ai': patch +--- + +chore (ai): move mockId test helper into provider utils diff --git a/content/providers/01-ai-sdk-providers/70-perplexity.mdx b/content/providers/01-ai-sdk-providers/70-perplexity.mdx index 5dead3ce95d4..50756615cdc3 100644 --- a/content/providers/01-ai-sdk-providers/70-perplexity.mdx +++ b/content/providers/01-ai-sdk-providers/70-perplexity.mdx @@ -77,12 +77,26 @@ const { text } = await generateText({ }); ``` -Perplexity models can be used in the `streamText` and `streamUI` functions (see -[AI SDK Core](/docs/ai-sdk-core) and [AI SDK RSC](/docs/ai-sdk-rsc)). +### Sources -### Provider Metadata +Websites that have been used to generate the response are included in the `sources` property of the result: -The Perplexity provider includes additional experimental metadata in the response through `providerMetadata`: +```ts +import { perplexity } from '@ai-sdk/perplexity'; +import { generateText } from 'ai'; + +const { text, sources } = await generateText({ + model: perplexity('sonar-pro'), + prompt: 'What are the latest developments in quantum computing?', +}); + +console.log(sources); +``` + +### Provider Options & Metadata + +The Perplexity provider includes additional metadata in the response through `providerMetadata`. +Additional configuration options are available through `providerOptions`. ```ts const result = await generateText({ @@ -99,10 +113,6 @@ console.log(result.providerMetadata); // Example output: // { // perplexity: { -// citations: [ -// 'https://www.sfchronicle.com', -// 'https://www.cbsnews.com/sanfrancisco/', -// ], // usage: { citationTokens: 5286, numSearchQueries: 1 }, // images: [ // { imageUrl: "https://example.com/image1.jpg", originUrl: "https://elsewhere.com/page1", height: 1280, width: 720 }, @@ -114,7 +124,6 @@ console.log(result.providerMetadata); The metadata includes: -- `citations`: Array of URLs used as sources for the response - `usage`: Object containing `citationTokens` and `numSearchQueries` metrics - `images`: Array of image URLs when `return_images` is enabled (Tier-2 users only) @@ -132,14 +141,6 @@ You can enable image responses by setting `return_images: true` in the provider | `sonar-pro` | | | | | | `sonar` | | | | | -### Key Features - -- **Real-time Web Search**: Both models provide grounded responses using real-time web search -- **Citations**: Sonar Pro provides 2x more citations than standard Sonar -- **Data Privacy**: No training on customer data -- **Self-serve API**: Immediate access with scalable pricing -- **Advanced Queries**: Support for complex queries and follow-up questions - Please see the [Perplexity docs](https://docs.perplexity.ai) for detailed API documentation and the latest updates. diff --git a/examples/ai-core/src/e2e/perplexity.test.ts b/examples/ai-core/src/e2e/perplexity.test.ts index f9cee0ba6a6a..78145a085529 100644 --- a/examples/ai-core/src/e2e/perplexity.test.ts +++ b/examples/ai-core/src/e2e/perplexity.test.ts @@ -1,9 +1,6 @@ import 'dotenv/config'; import { expect } from 'vitest'; -import { - perplexity as provider, - PerplexityErrorData, -} from '@ai-sdk/perplexity'; +import { perplexity as provider } from '@ai-sdk/perplexity'; import { createFeatureTestSuite, createLanguageModelWithCapabilities, @@ -11,18 +8,18 @@ import { import { APICallError } from '@ai-sdk/provider'; const createChatModel = (modelId: string) => - createLanguageModelWithCapabilities(provider.chat(modelId)); + createLanguageModelWithCapabilities(provider(modelId)); createFeatureTestSuite({ name: 'perplexity', models: { - invalidModel: provider.chat('no-such-model'), + invalidModel: provider('no-such-model'), languageModels: [createChatModel('sonar-pro'), createChatModel('sonar')], }, timeout: 30000, customAssertions: { errorValidator: (error: APICallError) => { - expect((error.data as PerplexityErrorData).code).toBe( + expect((error.data as any).code).toBe( 'Some requested entity was not found', ); }, diff --git a/examples/ai-core/src/generate-object/perplexity.ts b/examples/ai-core/src/generate-object/perplexity.ts new file mode 100644 index 000000000000..84ccb1e246c9 --- /dev/null +++ b/examples/ai-core/src/generate-object/perplexity.ts @@ -0,0 +1,31 @@ +import 'dotenv/config'; +import { perplexity } from '@ai-sdk/perplexity'; +import { generateObject, generateText } from 'ai'; +import { z } from 'zod'; + +async function main() { + const result = await generateObject({ + model: perplexity('sonar-pro'), + prompt: 'What has happened in San Francisco recently?', + providerOptions: { + perplexity: { + search_recency_filter: 'week', + }, + }, + output: 'array', + schema: z.object({ + title: z.string(), + summary: z.string(), + }), + }); + + console.log(result.object); + console.log(); + console.log('Token usage:', result.usage); + console.log('Finish reason:', result.finishReason); + console.log('Metadata:', result.providerMetadata); +} + +main().catch((error: Error) => { + console.error(JSON.stringify(error, null, 2)); +}); diff --git a/examples/ai-core/src/generate-text/perplexity.ts b/examples/ai-core/src/generate-text/perplexity.ts index 1612c15cc4ba..fbbaad5f19dc 100644 --- a/examples/ai-core/src/generate-text/perplexity.ts +++ b/examples/ai-core/src/generate-text/perplexity.ts @@ -5,11 +5,17 @@ import { generateText } from 'ai'; async function main() { const result = await generateText({ model: perplexity('sonar-pro'), - prompt: 'Invent a new holiday and describe its traditions.', + prompt: 'What has happened in San Francisco recently?', + providerOptions: { + perplexity: { + search_recency_filter: 'week', + }, + }, }); console.log(result.text); console.log(); + console.log('Sources:', result.sources); console.log('Token usage:', result.usage); console.log('Finish reason:', result.finishReason); console.log('Metadata:', result.providerMetadata); diff --git a/examples/ai-core/src/stream-text/perplexity.ts b/examples/ai-core/src/stream-text/perplexity.ts index c6334c42ee17..67c5a69bdc63 100644 --- a/examples/ai-core/src/stream-text/perplexity.ts +++ b/examples/ai-core/src/stream-text/perplexity.ts @@ -5,9 +5,12 @@ import 'dotenv/config'; async function main() { const result = streamText({ model: perplexity('sonar-pro'), - prompt: - 'List the top 5 San Francisco news from the past week.' + - 'You must include the date of each article.', + prompt: 'What has happened in San Francisco recently?', + providerOptions: { + perplexity: { + search_recency_filter: 'week', + }, + }, }); for await (const textPart of result.textStream) { @@ -15,6 +18,7 @@ async function main() { } console.log(); + console.log('Sources:', await result.sources); console.log('Token usage:', await result.usage); console.log('Finish reason:', await result.finishReason); console.log( diff --git a/examples/next-openai/app/api/use-chat-sources/route.ts b/examples/next-openai/app/api/use-chat-sources/route.ts index de844fa1729a..eaf2a1b169a2 100644 --- a/examples/next-openai/app/api/use-chat-sources/route.ts +++ b/examples/next-openai/app/api/use-chat-sources/route.ts @@ -1,4 +1,5 @@ import { vertex } from '@ai-sdk/google-vertex'; +import { perplexity } from '@ai-sdk/perplexity'; import { streamText } from 'ai'; // Allow streaming responses up to 30 seconds @@ -8,7 +9,8 @@ export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ - model: vertex('gemini-1.5-flash', { useSearchGrounding: true }), + // model: vertex('gemini-1.5-flash', { useSearchGrounding: true }), + model: perplexity('sonar-pro'), messages, }); diff --git a/examples/next-openai/app/use-chat-sources/page.tsx b/examples/next-openai/app/use-chat-sources/page.tsx index 47bf4124202d..708425a94fa3 100644 --- a/examples/next-openai/app/use-chat-sources/page.tsx +++ b/examples/next-openai/app/use-chat-sources/page.tsx @@ -21,27 +21,28 @@ export default function Chat() { {messages.map(message => (
{message.role === 'user' ? 'User: ' : 'AI: '} - {message.parts.map((part, index) => { - if (part.type === 'text') { - return
{part.text}
; - } - - if (part.type === 'source') { - return ( - - [ - - {part.source.title} - - ] - - ); - } - })} + {message.parts + .filter(part => part.type !== 'source') + .map((part, index) => { + if (part.type === 'text') { + return
{part.text}
; + } + })} + {message.parts + .filter(part => part.type === 'source') + .map(part => ( + + [ + + {part.source.title ?? new URL(part.source.url).hostname} + + ] + + ))}
))} diff --git a/examples/next-openai/package.json b/examples/next-openai/package.json index c36ba785f840..8524913c2134 100644 --- a/examples/next-openai/package.json +++ b/examples/next-openai/package.json @@ -13,6 +13,7 @@ "@ai-sdk/deepseek": "0.1.9", "@ai-sdk/openai": "1.1.10", "@ai-sdk/google-vertex": "2.1.13", + "@ai-sdk/perplexity": "0.0.8", "@ai-sdk/ui-utils": "1.1.13", "@ai-sdk/react": "1.1.13", "@vercel/blob": "^0.26.0", diff --git a/packages/ai/core/generate-text/generate-text.test.ts b/packages/ai/core/generate-text/generate-text.test.ts index 469c824507ca..26e51906591d 100644 --- a/packages/ai/core/generate-text/generate-text.test.ts +++ b/packages/ai/core/generate-text/generate-text.test.ts @@ -1,10 +1,10 @@ import { LanguageModelV1CallOptions } from '@ai-sdk/provider'; +import { mockId } from '@ai-sdk/provider-utils/test'; import { jsonSchema } from '@ai-sdk/ui-utils'; import assert from 'node:assert'; import { z } from 'zod'; import { Output } from '.'; import { ToolExecutionError } from '../../errors'; -import { mockId } from '../test/mock-id'; import { MockLanguageModelV1 } from '../test/mock-language-model-v1'; import { MockTracer } from '../test/mock-tracer'; import { tool } from '../tool/tool'; diff --git a/packages/ai/core/generate-text/stream-text.test.ts b/packages/ai/core/generate-text/stream-text.test.ts index a9bdc9f4dbfb..6e3a82d8a8e2 100644 --- a/packages/ai/core/generate-text/stream-text.test.ts +++ b/packages/ai/core/generate-text/stream-text.test.ts @@ -10,6 +10,7 @@ import { convertAsyncIterableToArray, convertReadableStreamToArray, convertResponseStreamToArray, + mockId, } from '@ai-sdk/provider-utils/test'; import { jsonSchema } from '@ai-sdk/ui-utils'; import assert from 'node:assert'; @@ -17,7 +18,6 @@ import { z } from 'zod'; import { ToolExecutionError } from '../../errors/tool-execution-error'; import { StreamData } from '../../streams/stream-data'; import { createDataStream } from '../data-stream/create-data-stream'; -import { mockId } from '../test/mock-id'; import { MockLanguageModelV1 } from '../test/mock-language-model-v1'; import { createMockServerResponse } from '../test/mock-server-response'; import { MockTracer } from '../test/mock-tracer'; diff --git a/packages/ai/core/middleware/extract-reasoning-middleware.test.ts b/packages/ai/core/middleware/extract-reasoning-middleware.test.ts index 88a137cd7077..0740251410c0 100644 --- a/packages/ai/core/middleware/extract-reasoning-middleware.test.ts +++ b/packages/ai/core/middleware/extract-reasoning-middleware.test.ts @@ -1,10 +1,10 @@ import { convertArrayToReadableStream, convertAsyncIterableToArray, + mockId, } from '@ai-sdk/provider-utils/test'; import { generateText, streamText } from '../generate-text'; import { wrapLanguageModel } from '../middleware/wrap-language-model'; -import { mockId } from '../test/mock-id'; import { MockLanguageModelV1 } from '../test/mock-language-model-v1'; import { extractReasoningMiddleware } from './extract-reasoning-middleware'; diff --git a/packages/ai/test/index.ts b/packages/ai/test/index.ts index 19303f530886..9cd5f65fd064 100644 --- a/packages/ai/test/index.ts +++ b/packages/ai/test/index.ts @@ -1,6 +1,8 @@ -export { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; +export { + convertArrayToReadableStream, + mockId, +} from '@ai-sdk/provider-utils/test'; export { MockEmbeddingModelV1 } from '../core/test/mock-embedding-model-v1'; -export { mockId } from '../core/test/mock-id'; export { MockLanguageModelV1 } from '../core/test/mock-language-model-v1'; export { mockValues } from '../core/test/mock-values'; diff --git a/packages/perplexity/package.json b/packages/perplexity/package.json index f0f480f9174a..87b16d857ae7 100644 --- a/packages/perplexity/package.json +++ b/packages/perplexity/package.json @@ -30,7 +30,6 @@ } }, "dependencies": { - "@ai-sdk/openai-compatible": "0.1.9", "@ai-sdk/provider": "1.0.7", "@ai-sdk/provider-utils": "2.1.7" }, diff --git a/packages/perplexity/src/__snapshots__/convert-to-perplexity-messages.test.ts.snap b/packages/perplexity/src/__snapshots__/convert-to-perplexity-messages.test.ts.snap new file mode 100644 index 000000000000..108637d38eed --- /dev/null +++ b/packages/perplexity/src/__snapshots__/convert-to-perplexity-messages.test.ts.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`convertToPerplexityMessages > assistant messages > should convert an assistant message with text content 1`] = ` +[ + { + "content": "Assistant reply", + "role": "assistant", + }, +] +`; + +exports[`convertToPerplexityMessages > system messages > should convert a system message with text content 1`] = ` +[ + { + "content": "System initialization", + "role": "system", + }, +] +`; + +exports[`convertToPerplexityMessages > user messages > should convert a user message with text parts 1`] = ` +[ + { + "content": "Hello World", + "role": "user", + }, +] +`; diff --git a/packages/perplexity/src/convert-to-perplexity-messages.test.ts b/packages/perplexity/src/convert-to-perplexity-messages.test.ts new file mode 100644 index 000000000000..cef836f255a3 --- /dev/null +++ b/packages/perplexity/src/convert-to-perplexity-messages.test.ts @@ -0,0 +1,116 @@ +import { convertToPerplexityMessages } from './convert-to-perplexity-messages'; +import { UnsupportedFunctionalityError } from '@ai-sdk/provider'; + +describe('convertToPerplexityMessages', () => { + describe('system messages', () => { + it('should convert a system message with text content', () => { + expect( + convertToPerplexityMessages([ + { + role: 'system', + content: 'System initialization', + }, + ]), + ).toMatchSnapshot(); + }); + }); + + describe('user messages', () => { + it('should convert a user message with text parts', () => { + expect( + convertToPerplexityMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'World' }, + ], + }, + ]), + ).toMatchSnapshot(); + }); + + it('should throw an error for user messages with image parts', () => { + expect(() => { + convertToPerplexityMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello ' }, + { + type: 'image', + image: new Uint8Array([0, 1, 2, 3]), + mimeType: 'image/png', + }, + ], + }, + ]); + }).toThrow(UnsupportedFunctionalityError); + }); + + it('should throw an error for user messages with file parts', () => { + expect(() => { + convertToPerplexityMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'Document: ' }, + { type: 'file', data: 'dummy-data', mimeType: 'text/plain' }, + ], + }, + ]); + }).toThrow(UnsupportedFunctionalityError); + }); + }); + + describe('assistant messages', () => { + it('should convert an assistant message with text content', () => { + expect( + convertToPerplexityMessages([ + { + role: 'assistant', + content: [{ type: 'text', text: 'Assistant reply' }], + }, + ]), + ).toMatchSnapshot(); + }); + + it('should throw an error for assistant messages with tool-call parts', () => { + expect(() => { + convertToPerplexityMessages([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + args: { key: 'value' }, + toolCallId: 'call-1', + toolName: 'example-tool', + }, + ], + }, + ]); + }).toThrow(UnsupportedFunctionalityError); + }); + }); + + describe('tool messages', () => { + it('should throw an error for tool messages', () => { + expect(() => { + convertToPerplexityMessages([ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'dummy-tool-call-id', + toolName: 'dummy-tool-name', + result: 'This should fail', + }, + ], + }, + ]); + }).toThrow(UnsupportedFunctionalityError); + }); + }); +}); diff --git a/packages/perplexity/src/convert-to-perplexity-messages.ts b/packages/perplexity/src/convert-to-perplexity-messages.ts new file mode 100644 index 000000000000..d0282787a246 --- /dev/null +++ b/packages/perplexity/src/convert-to-perplexity-messages.ts @@ -0,0 +1,67 @@ +import { + LanguageModelV1Prompt, + UnsupportedFunctionalityError, +} from '@ai-sdk/provider'; +import { PerplexityPrompt } from './perplexity-language-model-prompt'; + +export function convertToPerplexityMessages( + prompt: LanguageModelV1Prompt, +): PerplexityPrompt { + const messages: PerplexityPrompt = []; + + for (const { role, content } of prompt) { + switch (role) { + case 'system': { + messages.push({ role: 'system', content }); + break; + } + + case 'user': + case 'assistant': { + messages.push({ + role, + content: content + .map(part => { + switch (part.type) { + case 'text': { + return part.text; + } + case 'image': { + throw new UnsupportedFunctionalityError({ + functionality: 'Image content parts in user messages', + }); + } + case 'file': { + throw new UnsupportedFunctionalityError({ + functionality: 'File content parts in user messages', + }); + } + case 'tool-call': { + throw new UnsupportedFunctionalityError({ + functionality: 'Tool calls in assistant messages', + }); + } + default: { + const _exhaustiveCheck: never = part; + throw new Error(`Unsupported part: ${_exhaustiveCheck}`); + } + } + }) + .join(''), + }); + break; + } + case 'tool': { + throw new UnsupportedFunctionalityError({ + functionality: 'Tool messages', + }); + } + default: { + const _exhaustiveCheck: never = role; + throw new Error(`Unsupported role: ${_exhaustiveCheck}`); + } + } + } + + return messages; +} diff --git a/packages/perplexity/src/index.ts b/packages/perplexity/src/index.ts index 3a3af86fc98e..c8d0dbdff943 100644 --- a/packages/perplexity/src/index.ts +++ b/packages/perplexity/src/index.ts @@ -1,6 +1,5 @@ export { createPerplexity, perplexity } from './perplexity-provider'; export type { - PerplexityErrorData, PerplexityProvider, PerplexityProviderSettings, } from './perplexity-provider'; diff --git a/packages/perplexity/src/map-perplexity-finish-reason.ts b/packages/perplexity/src/map-perplexity-finish-reason.ts new file mode 100644 index 000000000000..aee2004d5547 --- /dev/null +++ b/packages/perplexity/src/map-perplexity-finish-reason.ts @@ -0,0 +1,13 @@ +import { LanguageModelV1FinishReason } from '@ai-sdk/provider'; + +export function mapPerplexityFinishReason( + finishReason: string | null | undefined, +): LanguageModelV1FinishReason { + switch (finishReason) { + case 'stop': + case 'length': + return finishReason; + default: + return 'unknown'; + } +} diff --git a/packages/perplexity/src/perplexity-chat-settings.ts b/packages/perplexity/src/perplexity-chat-settings.ts deleted file mode 100644 index a6dc46254f35..000000000000 --- a/packages/perplexity/src/perplexity-chat-settings.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { OpenAICompatibleChatSettings } from '@ai-sdk/openai-compatible'; - -// https://docs.perplexity.ai/guides/model-cards -export type PerplexityChatModelId = 'sonar-pro' | 'sonar' | (string & {}); - -export interface PerplexityChatSettings extends OpenAICompatibleChatSettings {} diff --git a/packages/perplexity/src/perplexity-language-model-prompt.ts b/packages/perplexity/src/perplexity-language-model-prompt.ts new file mode 100644 index 000000000000..7c4f37778d32 --- /dev/null +++ b/packages/perplexity/src/perplexity-language-model-prompt.ts @@ -0,0 +1,6 @@ +export type PerplexityPrompt = Array; + +export type PerplexityMessage = { + role: 'system' | 'user' | 'assistant'; + content: string; +}; diff --git a/packages/perplexity/src/perplexity-language-model-settings.ts b/packages/perplexity/src/perplexity-language-model-settings.ts new file mode 100644 index 000000000000..8301e5bf26c7 --- /dev/null +++ b/packages/perplexity/src/perplexity-language-model-settings.ts @@ -0,0 +1,7 @@ +// https://docs.perplexity.ai/guides/model-cards +export type PerplexityLanguageModelId = + | 'sonar-reasoning-pro' + | 'sonar-reasoning' + | 'sonar-pro' + | 'sonar' + | (string & {}); diff --git a/packages/perplexity/src/perplexity-language-model.test.ts b/packages/perplexity/src/perplexity-language-model.test.ts new file mode 100644 index 000000000000..5c0a6a1c4243 --- /dev/null +++ b/packages/perplexity/src/perplexity-language-model.test.ts @@ -0,0 +1,638 @@ +import { + LanguageModelV1Prompt, + UnsupportedFunctionalityError, +} from '@ai-sdk/provider'; +import { + convertReadableStreamToArray, + createTestServer, + mockId, +} from '@ai-sdk/provider-utils/test'; +import { + perplexityImageSchema, + PerplexityLanguageModel, +} from './perplexity-language-model'; +import { z } from 'zod'; + +const TEST_PROMPT: LanguageModelV1Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, +]; + +describe('PerplexityLanguageModel', () => { + describe('doGenerate', () => { + const modelId = 'perplexity-001'; + + const perplexityLM = new PerplexityLanguageModel(modelId, { + baseURL: 'https://api.perplexity.ai', + headers: () => ({ + authorization: 'Bearer test-token', + 'content-type': 'application/json', + }), + generateId: mockId(), + }); + + // Create a unified test server to handle JSON responses. + const jsonServer = createTestServer({ + 'https://api.perplexity.ai/chat/completions': { + response: { + type: 'json-value', + headers: { 'content-type': 'application/json' }, + body: {}, + }, + }, + }); + + // Helper to prepare the JSON response for doGenerate. + function prepareJsonResponse({ + content = '', + usage = { prompt_tokens: 10, completion_tokens: 20 }, + id = 'test-id', + created = 1680000000, + model = modelId, + headers = {}, + citations = [], + images, + }: { + content?: string; + usage?: { + prompt_tokens: number; + completion_tokens: number; + citation_tokens?: number; + num_search_queries?: number; + }; + id?: string; + created?: number; + model?: string; + headers?: Record; + citations?: string[]; + images?: z.infer[]; + } = {}) { + jsonServer.urls['https://api.perplexity.ai/chat/completions'].response = { + type: 'json-value', + headers: { 'content-type': 'application/json', ...headers }, + body: { + id, + created, + model, + choices: [ + { + message: { + role: 'assistant', + content, + }, + finish_reason: 'stop', + }, + ], + citations, + images, + usage, + }, + }; + } + + it('should extract text response correctly', async () => { + prepareJsonResponse({ content: 'Hello, World!' }); + + const result = await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + expect(result.text).toBe('Hello, World!'); + expect(result.usage).toEqual({ + promptTokens: 10, + completionTokens: 20, + }); + expect(result.response).toEqual({ + id: 'test-id', + modelId, + timestamp: new Date(1680000000 * 1000), + }); + }); + + it('should send the correct request body', async () => { + prepareJsonResponse({ content: '' }); + await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + const requestBody = await jsonServer.calls[0].requestBody; + expect(requestBody).toEqual({ + model: modelId, + messages: [{ role: 'user', content: 'Hello' }], + }); + }); + + it('should pass through perplexity provider options', async () => { + prepareJsonResponse({ content: '' }); + await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + providerMetadata: { + perplexity: { + search_recency_filter: 'month', + return_images: true, + }, + }, + }); + + const requestBody = await jsonServer.calls[0].requestBody; + expect(requestBody).toEqual({ + model: modelId, + messages: [{ role: 'user', content: 'Hello' }], + search_recency_filter: 'month', + return_images: true, + }); + }); + + it('should extract citations as sources', async () => { + prepareJsonResponse({ + citations: ['http://example.com/123', 'https://example.com/456'], + }); + + const result = await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + expect(result.sources).toEqual([ + { + sourceType: 'url', + id: 'id-0', + url: 'http://example.com/123', + }, + { + sourceType: 'url', + id: 'id-1', + url: 'https://example.com/456', + }, + ]); + }); + + it('should extract images', async () => { + prepareJsonResponse({ + images: [ + { + image_url: 'https://example.com/image.jpg', + origin_url: 'https://example.com/image.jpg', + height: 100, + width: 100, + }, + ], + }); + + const result = await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + expect(result.providerMetadata).toStrictEqual({ + perplexity: { + images: [ + { + imageUrl: 'https://example.com/image.jpg', + originUrl: 'https://example.com/image.jpg', + height: 100, + width: 100, + }, + ], + usage: { + citationTokens: null, + numSearchQueries: null, + }, + }, + }); + }); + + it('should extract usage', async () => { + prepareJsonResponse({ + usage: { + prompt_tokens: 10, + completion_tokens: 20, + citation_tokens: 30, + num_search_queries: 40, + }, + }); + + const result = await perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + expect(result.usage).toEqual({ + promptTokens: 10, + completionTokens: 20, + }); + + expect(result.providerMetadata).toEqual({ + perplexity: { + images: null, + usage: { + citationTokens: 30, + numSearchQueries: 40, + }, + }, + }); + }); + + it('should pass headers from provider and request', async () => { + prepareJsonResponse({ content: '' }); + const lmWithCustomHeaders = new PerplexityLanguageModel(modelId, { + baseURL: 'https://api.perplexity.ai', + headers: () => ({ + authorization: 'Bearer test-api-key', + 'Custom-Provider-Header': 'provider-header-value', + }), + generateId: mockId(), + }); + + await lmWithCustomHeaders.doGenerate({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + headers: { 'Custom-Request-Header': 'request-header-value' }, + }); + + expect(jsonServer.calls[0].requestHeaders).toEqual({ + authorization: 'Bearer test-api-key', + 'content-type': 'application/json', + 'custom-provider-header': 'provider-header-value', + 'custom-request-header': 'request-header-value', + }); + }); + + it('should throw error for unsupported mode: object-tool', async () => { + await expect( + perplexityLM.doGenerate({ + inputFormat: 'prompt', + mode: { + type: 'object-tool', + tool: { type: 'function', name: 'test', parameters: {} }, + }, + prompt: TEST_PROMPT, + }), + ).rejects.toThrowError(UnsupportedFunctionalityError); + }); + }); + + describe('doStream', () => { + const modelId = 'perplexity-001'; + + const streamServer = createTestServer({ + 'https://api.perplexity.ai/chat/completions': { + response: { + type: 'stream-chunks', + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + chunks: [], + }, + }, + }); + + const perplexityLM = new PerplexityLanguageModel(modelId, { + baseURL: 'https://api.perplexity.ai', + headers: () => ({ authorization: 'Bearer test-token' }), + generateId: mockId(), + }); + + // Helper to prepare the stream response. + function prepareStreamResponse({ + contents, + usage = { prompt_tokens: 10, completion_tokens: 20 }, + citations = [], + images, + }: { + contents: string[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + citation_tokens?: number; + num_search_queries?: number; + }; + citations?: string[]; + images?: z.infer[]; + }) { + const baseChunk = ( + content: string, + finish_reason: string | null = null, + includeUsage = false, + ) => { + const chunkObj: any = { + id: 'stream-id', + created: 1680003600, + model: modelId, + images, + citations, + choices: [ + { + delta: { role: 'assistant', content }, + finish_reason, + }, + ], + }; + if (includeUsage) { + chunkObj.usage = usage; + } + return `data: ${JSON.stringify(chunkObj)}\n\n`; + }; + + streamServer.urls['https://api.perplexity.ai/chat/completions'].response = + { + type: 'stream-chunks', + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + chunks: [ + ...contents.slice(0, -1).map(text => baseChunk(text)), + // Final chunk: include finish_reason and usage. + baseChunk(contents[contents.length - 1], 'stop', true), + 'data: [DONE]\n\n', + ], + }; + } + + it('should stream text deltas', async () => { + prepareStreamResponse({ contents: ['Hello', ', ', 'World!'] }); + + const { stream } = await perplexityLM.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + const result = await convertReadableStreamToArray(stream); + + expect(result).toEqual([ + { + type: 'response-metadata', + id: 'stream-id', + timestamp: new Date(1680003600 * 1000), + modelId, + }, + { + type: 'text-delta', + textDelta: 'Hello', + }, + { + type: 'text-delta', + textDelta: ', ', + }, + { + type: 'text-delta', + textDelta: 'World!', + }, + { + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + providerMetadata: { + perplexity: { + images: null, + usage: { + citationTokens: null, + numSearchQueries: null, + }, + }, + }, + }, + ]); + }); + + it('should stream sources', async () => { + prepareStreamResponse({ + contents: ['Hello', ', ', 'World!'], + citations: ['http://example.com/123', 'https://example.com/456'], + }); + + const { stream } = await perplexityLM.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + const result = await convertReadableStreamToArray(stream); + + expect(result).toEqual([ + { + type: 'response-metadata', + id: 'stream-id', + timestamp: new Date(1680003600 * 1000), + modelId, + }, + { + type: 'source', + source: { + sourceType: 'url', + id: 'id-0', + url: 'http://example.com/123', + }, + }, + { + type: 'source', + source: { + sourceType: 'url', + id: 'id-1', + url: 'https://example.com/456', + }, + }, + { + type: 'text-delta', + textDelta: 'Hello', + }, + { + type: 'text-delta', + textDelta: ', ', + }, + { + type: 'text-delta', + textDelta: 'World!', + }, + { + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + providerMetadata: { + perplexity: { + images: null, + usage: { + citationTokens: null, + numSearchQueries: null, + }, + }, + }, + }, + ]); + }); + + it('should send the correct streaming request body', async () => { + prepareStreamResponse({ contents: [] }); + + await perplexityLM.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + const requestBody = await streamServer.calls[0].requestBody; + expect(requestBody).toEqual({ + model: modelId, + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + }); + }); + + it('should send usage', async () => { + prepareStreamResponse({ + contents: ['Hello', ', ', 'World!'], + images: [ + { + image_url: 'https://example.com/image.jpg', + origin_url: 'https://example.com/image.jpg', + height: 100, + width: 100, + }, + ], + }); + const { stream } = await perplexityLM.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + const result = await convertReadableStreamToArray(stream); + + expect(result).toEqual([ + { + id: 'stream-id', + modelId: 'perplexity-001', + timestamp: new Date('2023-03-28T11:40:00.000Z'), + type: 'response-metadata', + }, + { + type: 'text-delta', + textDelta: 'Hello', + }, + { + type: 'text-delta', + textDelta: ', ', + }, + { + type: 'text-delta', + textDelta: 'World!', + }, + { + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + providerMetadata: { + perplexity: { + images: [ + { + imageUrl: 'https://example.com/image.jpg', + originUrl: 'https://example.com/image.jpg', + height: 100, + width: 100, + }, + ], + usage: { + citationTokens: null, + numSearchQueries: null, + }, + }, + }, + }, + ]); + }); + + it('should send images', async () => { + prepareStreamResponse({ + contents: ['Hello', ', ', 'World!'], + usage: { + prompt_tokens: 11, + completion_tokens: 21, + citation_tokens: 30, + num_search_queries: 40, + }, + }); + + const { stream } = await perplexityLM.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + }); + + const result = await convertReadableStreamToArray(stream); + + expect(result).toStrictEqual([ + { + id: 'stream-id', + modelId: 'perplexity-001', + timestamp: new Date('2023-03-28T11:40:00.000Z'), + type: 'response-metadata', + }, + { + type: 'text-delta', + textDelta: 'Hello', + }, + { + type: 'text-delta', + textDelta: ', ', + }, + { + type: 'text-delta', + textDelta: 'World!', + }, + { + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 11, completionTokens: 21 }, + providerMetadata: { + perplexity: { + images: null, + usage: { + citationTokens: 30, + numSearchQueries: 40, + }, + }, + }, + }, + ]); + }); + + it('should pass headers', async () => { + prepareStreamResponse({ contents: [] }); + const lmWithCustomHeaders = new PerplexityLanguageModel(modelId, { + baseURL: 'https://api.perplexity.ai', + headers: () => ({ + authorization: 'Bearer test-api-key', + 'Custom-Provider-Header': 'provider-header-value', + }), + generateId: mockId(), + }); + + await lmWithCustomHeaders.doStream({ + inputFormat: 'prompt', + mode: { type: 'regular' }, + prompt: TEST_PROMPT, + headers: { 'Custom-Request-Header': 'request-header-value' }, + }); + + expect(streamServer.calls[0].requestHeaders).toEqual({ + authorization: 'Bearer test-api-key', + 'content-type': 'application/json', + 'custom-provider-header': 'provider-header-value', + 'custom-request-header': 'request-header-value', + }); + }); + }); +}); diff --git a/packages/perplexity/src/perplexity-language-model.ts b/packages/perplexity/src/perplexity-language-model.ts new file mode 100644 index 000000000000..c50dcb326186 --- /dev/null +++ b/packages/perplexity/src/perplexity-language-model.ts @@ -0,0 +1,429 @@ +import { + LanguageModelV1, + LanguageModelV1CallWarning, + LanguageModelV1FinishReason, + LanguageModelV1StreamPart, + UnsupportedFunctionalityError, +} from '@ai-sdk/provider'; +import { + FetchFunction, + ParseResult, + combineHeaders, + createEventSourceResponseHandler, + createJsonErrorResponseHandler, + createJsonResponseHandler, + postJsonToApi, +} from '@ai-sdk/provider-utils'; +import { z } from 'zod'; +import { PerplexityLanguageModelId } from './perplexity-language-model-settings'; +import { convertToPerplexityMessages } from './convert-to-perplexity-messages'; +import { mapPerplexityFinishReason } from './map-perplexity-finish-reason'; + +type PerplexityChatConfig = { + baseURL: string; + headers: () => Record; + generateId: () => string; + fetch?: FetchFunction; +}; + +export class PerplexityLanguageModel implements LanguageModelV1 { + readonly specificationVersion = 'v1'; + readonly defaultObjectGenerationMode = 'json'; + readonly supportsStructuredOutputs = true; + readonly supportsImageUrls = false; + readonly provider = 'perplexity'; + + readonly modelId: PerplexityLanguageModelId; + + private readonly config: PerplexityChatConfig; + + constructor( + modelId: PerplexityLanguageModelId, + config: PerplexityChatConfig, + ) { + this.modelId = modelId; + this.config = config; + } + + private getArgs({ + mode, + prompt, + maxTokens, + temperature, + topP, + topK, + frequencyPenalty, + presencePenalty, + stopSequences, + responseFormat, + seed, + providerMetadata, + }: Parameters[0]) { + const type = mode.type; + + const warnings: LanguageModelV1CallWarning[] = []; + + if (topK != null) { + warnings.push({ + type: 'unsupported-setting', + setting: 'topK', + }); + } + + if (stopSequences != null) { + warnings.push({ + type: 'unsupported-setting', + setting: 'stopSequences', + }); + } + + if (seed != null) { + warnings.push({ + type: 'unsupported-setting', + setting: 'seed', + }); + } + + const baseArgs = { + // model id: + model: this.modelId, + + // standardized settings: + frequency_penalty: frequencyPenalty, + max_tokens: maxTokens, + presence_penalty: presencePenalty, + temperature, + top_k: topK, + top_p: topP, + + // response format: + response_format: + responseFormat?.type === 'json' + ? { + type: 'json_schema', + json_schema: { schema: responseFormat.schema }, + } + : undefined, + + // provider extensions + ...(providerMetadata?.perplexity ?? {}), + + // messages: + messages: convertToPerplexityMessages(prompt), + }; + + switch (type) { + case 'regular': { + return { args: baseArgs, warnings }; + } + + case 'object-json': { + return { + args: { + ...baseArgs, + response_format: { + type: 'json_schema', + json_schema: { schema: mode.schema }, + }, + }, + warnings, + }; + } + + case 'object-tool': { + throw new UnsupportedFunctionalityError({ + functionality: 'tool-mode object generation', + }); + } + + default: { + const _exhaustiveCheck: never = type; + throw new Error(`Unsupported type: ${_exhaustiveCheck}`); + } + } + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const { args, warnings } = this.getArgs(options); + + const { responseHeaders, value: response } = await postJsonToApi({ + url: `${this.config.baseURL}/chat/completions`, + headers: combineHeaders(this.config.headers(), options.headers), + body: args, + failedResponseHandler: createJsonErrorResponseHandler({ + errorSchema: perplexityErrorSchema, + errorToMessage: data => data.error, + }), + successfulResponseHandler: createJsonResponseHandler( + perplexityResponseSchema, + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + const { messages: rawPrompt, ...rawSettings } = args; + const choice = response.choices[0]; + const text = choice.message.content; + + return { + text, + toolCalls: [], + finishReason: mapPerplexityFinishReason(choice.finish_reason), + usage: { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + }, + rawCall: { rawPrompt, rawSettings }, + rawResponse: { headers: responseHeaders }, + request: { body: JSON.stringify(args) }, + response: getResponseMetadata(response), + warnings, + sources: response.citations.map(url => ({ + sourceType: 'url', + id: this.config.generateId(), + url, + })), + providerMetadata: { + perplexity: { + images: + response.images?.map(image => ({ + imageUrl: image.image_url, + originUrl: image.origin_url, + height: image.height, + width: image.width, + })) ?? null, + usage: { + citationTokens: response.usage.citation_tokens ?? null, + numSearchQueries: response.usage.num_search_queries ?? null, + }, + }, + }, + }; + } + + async doStream( + options: Parameters[0], + ): Promise>> { + const { args, warnings } = this.getArgs(options); + + const body = { ...args, stream: true }; + + const { responseHeaders, value: response } = await postJsonToApi({ + url: `${this.config.baseURL}/chat/completions`, + headers: combineHeaders(this.config.headers(), options.headers), + body, + failedResponseHandler: createJsonErrorResponseHandler({ + errorSchema: perplexityErrorSchema, + errorToMessage: data => data.error, + }), + successfulResponseHandler: createEventSourceResponseHandler( + perplexityChunkSchema, + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + const { messages: rawPrompt, ...rawSettings } = args; + + let finishReason: LanguageModelV1FinishReason = 'unknown'; + let usage: { promptTokens: number; completionTokens: number } = { + promptTokens: Number.NaN, + completionTokens: Number.NaN, + }; + const providerMetadata: { + perplexity: { + usage: { + citationTokens: number | null; + numSearchQueries: number | null; + }; + images: Array<{ + imageUrl: string; + originUrl: string; + height: number; + width: number; + }> | null; + }; + } = { + perplexity: { + usage: { + citationTokens: null, + numSearchQueries: null, + }, + images: null, + }, + }; + let isFirstChunk = true; + + const self = this; + + return { + stream: response.pipeThrough( + new TransformStream< + ParseResult>, + LanguageModelV1StreamPart + >({ + transform(chunk, controller) { + if (!chunk.success) { + controller.enqueue({ type: 'error', error: chunk.error }); + return; + } + + const value = chunk.value; + + if (isFirstChunk) { + controller.enqueue({ + type: 'response-metadata', + ...getResponseMetadata(value), + }); + + value.citations?.forEach(url => { + controller.enqueue({ + type: 'source', + source: { + sourceType: 'url', + id: self.config.generateId(), + url, + }, + }); + }); + + isFirstChunk = false; + } + + if (value.usage != null) { + usage = { + promptTokens: value.usage.prompt_tokens, + completionTokens: value.usage.completion_tokens, + }; + + providerMetadata.perplexity.usage = { + citationTokens: value.usage.citation_tokens ?? null, + numSearchQueries: value.usage.num_search_queries ?? null, + }; + } + + if (value.images != null) { + providerMetadata.perplexity.images = value.images.map(image => ({ + imageUrl: image.image_url, + originUrl: image.origin_url, + height: image.height, + width: image.width, + })); + } + + const choice = value.choices[0]; + if (choice?.finish_reason != null) { + finishReason = mapPerplexityFinishReason(choice.finish_reason); + } + + if (choice?.delta == null) { + return; + } + + const delta = choice.delta; + const textContent = delta.content; + + if (textContent != null) { + controller.enqueue({ + type: 'text-delta', + textDelta: textContent, + }); + } + }, + + flush(controller) { + controller.enqueue({ + type: 'finish', + finishReason, + usage, + providerMetadata, + }); + }, + }), + ), + rawCall: { rawPrompt, rawSettings }, + rawResponse: { headers: responseHeaders }, + request: { body: JSON.stringify(body) }, + warnings, + }; + } +} + +function getResponseMetadata({ + id, + model, + created, +}: { + id: string; + created: number; + model: string; +}) { + return { + id, + modelId: model, + timestamp: new Date(created * 1000), + }; +} + +const perplexityUsageSchema = z.object({ + prompt_tokens: z.number(), + completion_tokens: z.number(), + citation_tokens: z.number().nullish(), + num_search_queries: z.number().nullish(), +}); + +export const perplexityImageSchema = z.object({ + image_url: z.string(), + origin_url: z.string(), + height: z.number(), + width: z.number(), +}); + +// limited version of the schema, focussed on what is needed for the implementation +// this approach limits breakages when the API changes and increases efficiency +const perplexityResponseSchema = z.object({ + id: z.string(), + created: z.number(), + model: z.string(), + choices: z.array( + z.object({ + message: z.object({ + role: z.literal('assistant'), + content: z.string(), + }), + finish_reason: z.string(), + }), + ), + citations: z.array(z.string()), + images: z.array(perplexityImageSchema).nullish(), + usage: perplexityUsageSchema, +}); + +// limited version of the schema, focussed on what is needed for the implementation +// this approach limits breakages when the API changes and increases efficiency +const perplexityChunkSchema = z.object({ + id: z.string(), + created: z.number(), + model: z.string(), + choices: z.array( + z.object({ + delta: z.object({ + role: z.literal('assistant'), + content: z.string(), + }), + finish_reason: z.string().nullish(), + }), + ), + citations: z.array(z.string()).nullish(), + images: z.array(perplexityImageSchema).nullish(), + usage: perplexityUsageSchema.nullish(), +}); + +export const perplexityErrorSchema = z.object({ + code: z.string(), + error: z.string(), +}); + +export type PerplexityErrorData = z.infer; diff --git a/packages/perplexity/src/perplexity-metadata-extractor.test.ts b/packages/perplexity/src/perplexity-metadata-extractor.test.ts deleted file mode 100644 index 9d98446a4f21..000000000000 --- a/packages/perplexity/src/perplexity-metadata-extractor.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { perplexityMetadataExtractor } from './perplexity-metadata-extractor'; - -describe('buildMetadataFromResponse', () => { - it('should extract metadata from complete response with citations, images and usage', () => { - const response = { - citations: ['source1', 'source2'], - images: [ - { - image_url: 'https://images.com/image1.jpg', - origin_url: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - usage: { - citation_tokens: 100, - num_search_queries: 5, - }, - }; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toEqual({ - perplexity: { - citations: ['source1', 'source2'], - images: [ - { - imageUrl: 'https://images.com/image1.jpg', - originUrl: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - usage: { - citationTokens: 100, - numSearchQueries: 5, - }, - }, - }); - }); - - it('should extract metadata with only citations', () => { - const response = { - citations: ['source1', 'source2'], - }; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toEqual({ - perplexity: { - citations: ['source1', 'source2'], - }, - }); - }); - - it('should extract metadata with only images', () => { - const response = { - images: [ - { - image_url: 'https://images.com/image1.jpg', - origin_url: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - }; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toEqual({ - perplexity: { - images: [ - { - imageUrl: 'https://images.com/image1.jpg', - originUrl: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - }, - }); - }); - - it('should extract metadata with only usage', () => { - const response = { - usage: { - citation_tokens: 100, - num_search_queries: 5, - }, - }; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toEqual({ - perplexity: { - usage: { - citationTokens: 100, - numSearchQueries: 5, - }, - }, - }); - }); - - it('should handle missing metadata', () => { - const response = { - id: 'test-id', - choices: [], - }; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toBeUndefined(); - }); - - it('should handle invalid response data', () => { - const response = 'invalid data'; - - const metadata = perplexityMetadataExtractor.extractMetadata({ - parsedBody: response, - }); - - expect(metadata).toBeUndefined(); - }); -}); - -describe('streaming metadata extractor', () => { - it('should process streaming chunks and build final metadata', () => { - const extractor = perplexityMetadataExtractor.createStreamExtractor(); - - // Process chunk with citations - extractor.processChunk({ - choices: [{ delta: { role: 'assistant', content: 'content' } }], - citations: ['source1', 'source2'], - }); - - // Process chunk with images - extractor.processChunk({ - choices: [{ delta: { role: 'assistant', content: 'content' } }], - images: [ - { - image_url: 'https://images.com/image1.jpg', - origin_url: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - }); - - // Process chunk with usage - extractor.processChunk({ - choices: [{ delta: { role: 'assistant', content: 'content' } }], - usage: { - citation_tokens: 100, - num_search_queries: 5, - }, - }); - - const finalMetadata = extractor.buildMetadata(); - - expect(finalMetadata).toEqual({ - perplexity: { - citations: ['source1', 'source2'], - images: [ - { - imageUrl: 'https://images.com/image1.jpg', - originUrl: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - usage: { - citationTokens: 100, - numSearchQueries: 5, - }, - }, - }); - }); - - it('should update metadata with latest chunk data', () => { - const extractor = perplexityMetadataExtractor.createStreamExtractor(); - - // Process initial chunk - extractor.processChunk({ - citations: ['source1'], - images: [ - { - image_url: 'https://images.com/image1.jpg', - origin_url: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - ], - usage: { - citation_tokens: 50, - num_search_queries: 2, - }, - }); - - // Process chunk with updated data - extractor.processChunk({ - citations: ['source1', 'source2'], - images: [ - { - image_url: 'https://images.com/image1.jpg', - origin_url: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - { - image_url: 'https://images.com/image2.jpg', - origin_url: 'https://elsewhere.com/page2', - height: 200, - width: 200, - }, - ], - usage: { - citation_tokens: 100, - num_search_queries: 5, - }, - }); - - const finalMetadata = extractor.buildMetadata(); - - expect(finalMetadata).toEqual({ - perplexity: { - citations: ['source1', 'source2'], - images: [ - { - imageUrl: 'https://images.com/image1.jpg', - originUrl: 'https://elsewhere.com/page1', - height: 100, - width: 100, - }, - { - imageUrl: 'https://images.com/image2.jpg', - originUrl: 'https://elsewhere.com/page2', - height: 200, - width: 200, - }, - ], - usage: { - citationTokens: 100, - numSearchQueries: 5, - }, - }, - }); - }); - - it('should handle streaming chunks without metadata', () => { - const extractor = perplexityMetadataExtractor.createStreamExtractor(); - - extractor.processChunk({ - choices: [{ delta: { role: 'assistant', content: 'content' } }], - }); - - const finalMetadata = extractor.buildMetadata(); - - expect(finalMetadata).toBeUndefined(); - }); - - it('should handle invalid streaming chunks', () => { - const extractor = perplexityMetadataExtractor.createStreamExtractor(); - - extractor.processChunk('invalid chunk'); - - const finalMetadata = extractor.buildMetadata(); - - expect(finalMetadata).toBeUndefined(); - }); - - it('should handle null values in usage data', () => { - const extractor = perplexityMetadataExtractor.createStreamExtractor(); - - extractor.processChunk({ - usage: { - citation_tokens: null, - num_search_queries: null, - }, - }); - - const finalMetadata = extractor.buildMetadata(); - - expect(finalMetadata).toEqual({ - perplexity: { - usage: { - citationTokens: NaN, - numSearchQueries: NaN, - }, - }, - }); - }); -}); diff --git a/packages/perplexity/src/perplexity-metadata-extractor.ts b/packages/perplexity/src/perplexity-metadata-extractor.ts deleted file mode 100644 index 10f7563a2686..000000000000 --- a/packages/perplexity/src/perplexity-metadata-extractor.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { MetadataExtractor } from '@ai-sdk/openai-compatible'; -import { safeValidateTypes } from '@ai-sdk/provider-utils'; -import { z } from 'zod'; - -export const perplexityMetadataExtractor: MetadataExtractor = { - extractMetadata: ({ parsedBody }: { parsedBody: unknown }) => { - const parsed = safeValidateTypes({ - value: parsedBody, - schema: perplexityResponseSchema, - }); - - return !parsed.success - ? undefined - : buildPerplexityMetadata( - parsed.value.citations ?? undefined, - parsed.value.images ?? undefined, - parsed.value.usage ?? undefined, - ); - }, - - createStreamExtractor: () => { - let citations: string[] | undefined; - let images: PerplexityImageData[] | undefined; - let usage: PerplexityUsageData | undefined; - - return { - processChunk: (chunk: unknown) => { - const parsed = safeValidateTypes({ - value: chunk, - schema: perplexityStreamChunkSchema, - }); - - if (parsed.success) { - citations = parsed.value.citations ?? citations; - images = parsed.value.images ?? images; - usage = parsed.value.usage ?? usage; - } - }, - buildMetadata: () => buildPerplexityMetadata(citations, images, usage), - }; - }, -}; - -const buildPerplexityMetadata = ( - citations: string[] | undefined, - images: PerplexityImageData[] | undefined, - usage: PerplexityUsageData | undefined, -) => { - return citations || images || usage - ? { - perplexity: { - ...(citations && { citations }), - ...(images && { - images: images.map(image => ({ - imageUrl: image.image_url, - originUrl: image.origin_url, - height: image.height, - width: image.width, - })), - }), - ...(usage && { - usage: { - citationTokens: usage.citation_tokens ?? NaN, - numSearchQueries: usage.num_search_queries ?? NaN, - }, - }), - }, - } - : undefined; -}; - -const perplexityCitationSchema = z.array(z.string()); - -const perplexityImageSchema = z.object({ - image_url: z.string(), - origin_url: z.string(), - height: z.number(), - width: z.number(), -}); - -type PerplexityImageData = z.infer; - -const perplexityUsageSchema = z.object({ - citation_tokens: z.number().nullish(), - num_search_queries: z.number().nullish(), -}); - -type PerplexityUsageData = z.infer; - -const perplexityResponseSchema = z.object({ - citations: perplexityCitationSchema.nullish(), - images: z.array(perplexityImageSchema).nullish(), - usage: perplexityUsageSchema.nullish(), -}); - -const perplexityStreamChunkSchema = z.object({ - choices: z - .array( - z.object({ - finish_reason: z.string().nullish(), - delta: z - .object({ - role: z.string(), - content: z.string(), - }) - .nullish(), - }), - ) - .nullish(), - citations: perplexityCitationSchema.nullish(), - images: z.array(perplexityImageSchema).nullish(), - usage: perplexityUsageSchema.nullish(), -}); diff --git a/packages/perplexity/src/perplexity-provider.test.ts b/packages/perplexity/src/perplexity-provider.test.ts deleted file mode 100644 index e96e9d060f24..000000000000 --- a/packages/perplexity/src/perplexity-provider.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; -import { createPerplexity } from './perplexity-provider'; -import { loadApiKey } from '@ai-sdk/provider-utils'; -import { OpenAICompatibleChatLanguageModel } from '@ai-sdk/openai-compatible'; - -const OpenAICompatibleChatLanguageModelMock = - OpenAICompatibleChatLanguageModel as unknown as Mock; - -vi.mock('@ai-sdk/openai-compatible', () => ({ - OpenAICompatibleChatLanguageModel: vi.fn(), - OpenAICompatibleCompletionLanguageModel: vi.fn(), - OpenAICompatibleEmbeddingModel: vi.fn(), -})); - -vi.mock('@ai-sdk/provider-utils', () => ({ - loadApiKey: vi.fn().mockReturnValue('mock-api-key'), - withoutTrailingSlash: vi.fn(url => url), -})); - -describe('perplexityProvider', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('createPerplexity', () => { - it('should create a PerplexityProvider instance with default options', () => { - const provider = createPerplexity(); - const model = provider('model-id'); - - const constructorCall = - OpenAICompatibleChatLanguageModelMock.mock.calls[0]; - const config = constructorCall[2]; - config.headers(); - - expect(loadApiKey).toHaveBeenCalledWith({ - apiKey: undefined, - environmentVariableName: 'PERPLEXITY_API_KEY', - description: 'Perplexity', - }); - }); - - it('should create a PerplexityProvider instance with custom options', () => { - const options = { - apiKey: 'custom-key', - baseURL: 'https://custom.url', - headers: { 'Custom-Header': 'value' }, - }; - const provider = createPerplexity(options); - provider('model-id'); - - const constructorCall = - OpenAICompatibleChatLanguageModelMock.mock.calls[0]; - const config = constructorCall[2]; - config.headers(); - - expect(loadApiKey).toHaveBeenCalledWith({ - apiKey: 'custom-key', - environmentVariableName: 'PERPLEXITY_API_KEY', - description: 'Perplexity', - }); - }); - - it('should return a chat model when called as a function', () => { - const provider = createPerplexity(); - const modelId = 'foo-model-id'; - const settings = { user: 'foo-user' }; - - const model = provider(modelId, settings); - expect(model).toBeInstanceOf(OpenAICompatibleChatLanguageModel); - }); - }); - - describe('chatModel', () => { - it('should construct a chat model with correct configuration', () => { - const provider = createPerplexity(); - const modelId = 'perplexity-chat-model'; - const settings = { user: 'foo-user' }; - - const model = provider.chat(modelId, settings); - - expect(model).toBeInstanceOf(OpenAICompatibleChatLanguageModel); - }); - }); -}); diff --git a/packages/perplexity/src/perplexity-provider.ts b/packages/perplexity/src/perplexity-provider.ts index 70641a930bed..862b18738d88 100644 --- a/packages/perplexity/src/perplexity-provider.ts +++ b/packages/perplexity/src/perplexity-provider.ts @@ -3,57 +3,25 @@ import { NoSuchModelError, ProviderV1, } from '@ai-sdk/provider'; -import { OpenAICompatibleChatLanguageModel } from '@ai-sdk/openai-compatible'; import { FetchFunction, + generateId, loadApiKey, withoutTrailingSlash, } from '@ai-sdk/provider-utils'; -import { - PerplexityChatModelId, - PerplexityChatSettings, -} from './perplexity-chat-settings'; -import { z } from 'zod'; -import { ProviderErrorStructure } from '@ai-sdk/openai-compatible'; -import { perplexityMetadataExtractor } from './perplexity-metadata-extractor'; - -// Add error schema and structure -const perplexityErrorSchema = z.object({ - code: z.string(), - error: z.string(), -}); - -export type PerplexityErrorData = z.infer; - -const perplexityErrorStructure: ProviderErrorStructure = { - errorSchema: perplexityErrorSchema, - errorToMessage: data => data.error, -}; +import { PerplexityLanguageModel } from './perplexity-language-model'; +import { PerplexityLanguageModelId } from './perplexity-language-model-settings'; export interface PerplexityProvider extends ProviderV1 { /** Creates an Perplexity chat model for text generation. */ - ( - modelId: PerplexityChatModelId, - settings?: PerplexityChatSettings, - ): LanguageModelV1; + (modelId: PerplexityLanguageModelId): LanguageModelV1; /** Creates an Perplexity language model for text generation. */ - languageModel( - modelId: PerplexityChatModelId, - settings?: PerplexityChatSettings, - ): LanguageModelV1; - - /** -Creates an Perplexity chat model for text generation. - */ - chat: ( - modelId: PerplexityChatModelId, - settings?: PerplexityChatSettings, - ) => LanguageModelV1; + languageModel(modelId: PerplexityLanguageModelId): LanguageModelV1; } export interface PerplexityProviderSettings { @@ -82,9 +50,6 @@ or to provide a custom fetch implementation for e.g. testing. export function createPerplexity( options: PerplexityProviderSettings = {}, ): PerplexityProvider { - const baseURL = withoutTrailingSlash( - options.baseURL ?? 'https://api.perplexity.ai', - ); const getHeaders = () => ({ Authorization: `Bearer ${loadApiKey({ apiKey: options.apiKey, @@ -94,29 +59,22 @@ export function createPerplexity( ...options.headers, }); - const createLanguageModel = ( - modelId: PerplexityChatModelId, - settings: PerplexityChatSettings = {}, - ) => { - return new OpenAICompatibleChatLanguageModel(modelId, settings, { - provider: 'perplexity.chat', - url: ({ path }) => `${baseURL}${path}`, + const createLanguageModel = (modelId: PerplexityLanguageModelId) => { + return new PerplexityLanguageModel(modelId, { + baseURL: withoutTrailingSlash( + options.baseURL ?? 'https://api.perplexity.ai', + )!, headers: getHeaders, + generateId, fetch: options.fetch, - defaultObjectGenerationMode: 'json', - errorStructure: perplexityErrorStructure, - metadataExtractor: perplexityMetadataExtractor, - supportsStructuredOutputs: true, }); }; - const provider = ( - modelId: PerplexityChatModelId, - settings?: PerplexityChatSettings, - ) => createLanguageModel(modelId, settings); + const provider = (modelId: PerplexityLanguageModelId) => + createLanguageModel(modelId); provider.languageModel = createLanguageModel; - provider.chat = createLanguageModel; + provider.textEmbeddingModel = (modelId: string) => { throw new NoSuchModelError({ modelId, modelType: 'textEmbeddingModel' }); }; diff --git a/packages/provider-utils/src/test/index.ts b/packages/provider-utils/src/test/index.ts index 874b38ed8148..e6e50179195b 100644 --- a/packages/provider-utils/src/test/index.ts +++ b/packages/provider-utils/src/test/index.ts @@ -4,6 +4,7 @@ export * from './convert-async-iterable-to-array'; export * from './convert-readable-stream-to-array'; export * from './convert-response-stream-to-array'; export * from './json-test-server'; +export * from './mock-id'; export * from './streaming-test-server'; export * from './test-server'; export * from './unified-test-server'; diff --git a/packages/ai/core/test/mock-id.ts b/packages/provider-utils/src/test/mock-id.ts similarity index 100% rename from packages/ai/core/test/mock-id.ts rename to packages/provider-utils/src/test/mock-id.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41059854310c..3d555088f9fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: '@ai-sdk/openai': specifier: 1.1.10 version: link:../../packages/openai + '@ai-sdk/perplexity': + specifier: 0.0.8 + version: link:../../packages/perplexity '@ai-sdk/react': specifier: 1.1.13 version: link:../../packages/react @@ -1668,9 +1671,6 @@ importers: packages/perplexity: dependencies: - '@ai-sdk/openai-compatible': - specifier: 0.1.9 - version: link:../openai-compatible '@ai-sdk/provider': specifier: 1.0.7 version: link:../provider