@ai-sdk/perplexity
Version:
The **[Perplexity provider](https://ai-sdk.dev/providers/ai-sdk-providers/perplexity)** for the [AI SDK](https://ai-sdk.dev/docs) contains language model support for Perplexity's Sonar API - a powerful answer engine with real-time web search capabilities.
443 lines (390 loc) • 11.8 kB
text/typescript
import {
LanguageModelV3,
LanguageModelV3CallOptions,
LanguageModelV3Content,
LanguageModelV3FinishReason,
LanguageModelV3GenerateResult,
LanguageModelV3StreamPart,
LanguageModelV3StreamResult,
SharedV3Warning,
} from '@ai-sdk/provider';
import {
FetchFunction,
ParseResult,
combineHeaders,
createEventSourceResponseHandler,
createJsonErrorResponseHandler,
createJsonResponseHandler,
postJsonToApi,
} from '@ai-sdk/provider-utils';
import { z } from 'zod/v4';
import { convertPerplexityUsage } from './convert-perplexity-usage';
import { convertToPerplexityMessages } from './convert-to-perplexity-messages';
import { mapPerplexityFinishReason } from './map-perplexity-finish-reason';
import { PerplexityLanguageModelId } from './perplexity-language-model-options';
type PerplexityChatConfig = {
baseURL: string;
headers: () => Record<string, string | undefined>;
generateId: () => string;
fetch?: FetchFunction;
};
export class PerplexityLanguageModel implements LanguageModelV3 {
readonly specificationVersion = 'v3';
readonly provider = 'perplexity';
readonly modelId: PerplexityLanguageModelId;
private readonly config: PerplexityChatConfig;
constructor(
modelId: PerplexityLanguageModelId,
config: PerplexityChatConfig,
) {
this.modelId = modelId;
this.config = config;
}
readonly supportedUrls: Record<string, RegExp[]> = {
// No URLs are supported.
};
private getArgs({
prompt,
maxOutputTokens,
temperature,
topP,
topK,
frequencyPenalty,
presencePenalty,
stopSequences,
responseFormat,
seed,
providerOptions,
}: LanguageModelV3CallOptions) {
const warnings: SharedV3Warning[] = [];
if (topK != null) {
warnings.push({ type: 'unsupported', feature: 'topK' });
}
if (stopSequences != null) {
warnings.push({ type: 'unsupported', feature: 'stopSequences' });
}
if (seed != null) {
warnings.push({ type: 'unsupported', feature: 'seed' });
}
return {
args: {
// model id:
model: this.modelId,
// standardized settings:
frequency_penalty: frequencyPenalty,
max_tokens: maxOutputTokens,
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
...(providerOptions?.perplexity ?? {}),
// messages:
messages: convertToPerplexityMessages(prompt),
},
warnings,
};
}
async doGenerate(
options: LanguageModelV3CallOptions,
): Promise<LanguageModelV3GenerateResult> {
const { args: body, warnings } = this.getArgs(options);
const {
responseHeaders,
value: response,
rawValue: rawResponse,
} = await postJsonToApi({
url: `${this.config.baseURL}/chat/completions`,
headers: combineHeaders(this.config.headers(), options.headers),
body,
failedResponseHandler: createJsonErrorResponseHandler({
errorSchema: perplexityErrorSchema,
errorToMessage,
}),
successfulResponseHandler: createJsonResponseHandler(
perplexityResponseSchema,
),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
const choice = response.choices[0];
const content: Array<LanguageModelV3Content> = [];
// text content:
const text = choice.message.content;
if (text.length > 0) {
content.push({ type: 'text', text });
}
// sources:
if (response.citations != null) {
for (const url of response.citations) {
content.push({
type: 'source',
sourceType: 'url',
id: this.config.generateId(),
url,
});
}
}
return {
content,
finishReason: {
unified: mapPerplexityFinishReason(choice.finish_reason),
raw: choice.finish_reason ?? undefined,
},
usage: convertPerplexityUsage(response.usage),
request: { body },
response: {
...getResponseMetadata(response),
headers: responseHeaders,
body: rawResponse,
},
warnings,
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: LanguageModelV3CallOptions,
): Promise<LanguageModelV3StreamResult> {
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,
}),
successfulResponseHandler: createEventSourceResponseHandler(
perplexityChunkSchema,
),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
let finishReason: LanguageModelV3FinishReason = {
unified: 'other',
raw: undefined,
};
let usage:
| {
prompt_tokens: number | undefined;
completion_tokens: number | undefined;
reasoning_tokens?: number | null | undefined;
}
| undefined = undefined;
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;
let isActive = false;
const self = this;
return {
stream: response.pipeThrough(
new TransformStream<
ParseResult<z.infer<typeof perplexityChunkSchema>>,
LanguageModelV3StreamPart
>({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings });
},
transform(chunk, controller) {
// Emit raw chunk if requested (before anything else)
if (options.includeRawChunks) {
controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });
}
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',
sourceType: 'url',
id: self.config.generateId(),
url,
});
});
isFirstChunk = false;
}
if (value.usage != null) {
usage = value.usage;
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 = {
unified: mapPerplexityFinishReason(choice.finish_reason),
raw: choice.finish_reason,
};
}
if (choice?.delta == null) {
return;
}
const delta = choice.delta;
const textContent = delta.content;
if (textContent != null) {
if (!isActive) {
controller.enqueue({ type: 'text-start', id: '0' });
isActive = true;
}
controller.enqueue({
type: 'text-delta',
id: '0',
delta: textContent,
});
}
},
flush(controller) {
if (isActive) {
controller.enqueue({ type: 'text-end', id: '0' });
}
controller.enqueue({
type: 'finish',
finishReason,
usage: convertPerplexityUsage(usage),
providerMetadata,
});
},
}),
),
request: { body },
response: { headers: responseHeaders },
};
}
}
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(),
total_tokens: z.number().nullish(),
citation_tokens: z.number().nullish(),
num_search_queries: z.number().nullish(),
reasoning_tokens: 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().nullish(),
}),
),
citations: z.array(z.string()).nullish(),
images: z.array(perplexityImageSchema).nullish(),
usage: perplexityUsageSchema.nullish(),
});
// 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({
error: z.object({
code: z.number(),
message: z.string().nullish(),
type: z.string().nullish(),
}),
});
export type PerplexityErrorData = z.infer<typeof perplexityErrorSchema>;
const errorToMessage = (data: PerplexityErrorData) => {
return data.error.message ?? data.error.type ?? 'unknown error';
};