diff --git a/.changeset/strong-agents-replace-sonar.md b/.changeset/strong-agents-replace-sonar.md
new file mode 100644
index 00000000..52161ed1
--- /dev/null
+++ b/.changeset/strong-agents-replace-sonar.md
@@ -0,0 +1,11 @@
+---
+'baseai': minor
+'@baseai/core': minor
+---
+
+Add Perplexity Agent API support with the `fast`, `low`, `medium`, and `high`
+presets for local BaseAI Pipes. The existing Perplexity Sonar identifiers remain
+on Chat Completions but are deprecated ahead of their scheduled September 27,
+2026 shutdown; existing Pipes are not migrated automatically. Agent responses
+preserve the executing model and available usage, provider HTTP errors retain
+their BaseAI status categories, and tool-free Core streams return immediately.
diff --git a/apps/baseai.dev/content/docs/docs/supported-models-and-providers.mdx b/apps/baseai.dev/content/docs/docs/supported-models-and-providers.mdx
index 5ffae1a5..f9363805 100644
--- a/apps/baseai.dev/content/docs/docs/supported-models-and-providers.mdx
+++ b/apps/baseai.dev/content/docs/docs/supported-models-and-providers.mdx
@@ -7,7 +7,7 @@ tags:
- llm providers
- models
published: 2024-09-24
-modified: 2024-09-24
+modified: 2026-08-28
---
# Supported LLM models and providers
@@ -150,7 +150,99 @@ Learn more about [using Ollama models](/docs/guides/using-ollama-models) in Base
### Perplexity
-| Model | Provider | Owner | Context | Cost* |
+Use a Perplexity Agent API preset for new local BaseAI integrations. Presets select and evolve their underlying model and built-in search tools dynamically.
+
+| Agent preset | BaseAI ID | Best for | Cost |
+|--------------|--------------------------------------------|--------------------------------------------------|---------|
+| Fast | | Quick lookups and short summaries with citations | Dynamic |
+| Low | | Everyday, light multi-step research | Dynamic |
+| Medium | | Multi-hop research across many sources | Dynamic |
+| High | | Deep research and broad source coverage | Dynamic |
+
+This first Agent integration supports system, user, and assistant text; non-streaming responses; streaming answer text; and preset search. Custom tools, tool-result replay, background runs, file outputs, and explicit Agent model selection are not supported. Citation metadata is included on non-streaming responses; streamed citation metadata is not exposed because the existing BaseAI stream chunk contract has no compatible citation field.
+
+
+These Agent identifiers are supported only by the public BaseAI OSS local runtime. This integration does not change, validate, or propose fixes for Langbase-hosted or other proprietary systems.
+
+
+
+Sonar Chat Completions is now [**Agent API**](https://docs.perplexity.ai/docs/agent-api/quickstart). Migrate by September 27, 2026. View the [**Migration Guide**](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview).
+
+The following legacy Perplexity Sonar identifiers remain routed through Chat Completions. Existing Pipes are not migrated automatically.
+
+
+ perplexity:llama-3.1-sonar-huge-128k-online
+ perplexity:llama-3.1-sonar-large-128k-online
+ perplexity:llama-3.1-sonar-small-128k-online
+ perplexity:llama-3.1-sonar-large-128k-chat
+ perplexity:llama-3.1-sonar-small-128k-chat
+
+
+
+#### Migrate a BaseAI Pipe
+
+Choose an Agent alias by workload from the table above; the historical BaseAI identifiers do not have an exact one-to-one mapping to today's Sonar product names. Existing Pipes are not changed automatically.
+
+For example, keep the rest of the Pipe configuration and replace its legacy model ID with the preset that fits the workload.
+
+Before:
+
+```ts
+const researchPipe = (): PipeI => ({
+ model: 'perplexity:llama-3.1-sonar-small-128k-online',
+ max_tokens: 1000,
+ temperature: 0.7,
+ top_p: 1,
+ // ...the remaining Pipe fields
+});
+```
+
+After:
+
+```ts
+const researchPipe = (): PipeI => ({
+ model: 'perplexity:fast',
+ max_tokens: 1000,
+ temperature: 0.7,
+ top_p: 1,
+ // ...the remaining Pipe fields
+});
+```
+
+BaseAI requires `max_tokens`, `temperature`, and `top_p`. Agent API receives them as request values (`max_tokens` becomes `max_output_tokens`), so they override the selected preset's tuned defaults. Review these values while migrating.
+
+Set both keys in the local caller's `.env` file:
+
+```bash
+LANGBASE_API_KEY="your-langbase-key"
+PERPLEXITY_API_KEY="your-perplexity-key"
+```
+
+`@baseai/core` reads `PERPLEXITY_API_KEY` from the local process and sends it as `llmApiKey` to the BaseAI server on `localhost:9000`; the local server uses it only for the Perplexity provider request. `LANGBASE_API_KEY` is read by the Pipe configuration. Keep both values server-side and out of source control.
+
+From the repository root, build and run the workspace-linked Node example:
+
+```bash
+pnpm install
+pnpm --filter example-nodejs check
+pnpm --filter example-nodejs baseai dev
+```
+
+Then run either request in a second terminal:
+
+```bash
+# Non-streaming answer plus citation metadata
+pnpm --filter example-nodejs pipe.perplexity.agent
+
+# Streaming answer text through Pipe.run({ stream: true })
+pnpm --filter example-nodejs pipe.perplexity.agent.stream
+```
+
+This PR changes only the public BaseAI local runtime; Langbase-hosted execution and rollout are outside its scope. Custom tools and tool-result replay, streamed citation metadata, background mode, file output, explicit Agent models, `xhigh`, and `wide-research` are not supported in this integration.
+
+Read the [migration overview](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview) for workload guidance and the [field-by-field migration guide](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/how-to) for request, response, citation, and streaming changes.
+
+| Legacy model | Provider | Owner | Context | Cost* |
|----------------------------------------------------------------------------------------------------------|------------|-------|---------|--------------------------------------------|
| llama-3.1-sonar-huge-128k-online
ID: | Perplexity | Meta | 127,072 | $5 prompt
$5 completion |
| llama-3.1-sonar-large-128k-online
ID: | Perplexity | Meta | 127,072 | $1 prompt
$1 completion |
diff --git a/examples/nodejs/baseai/pipes/perplexity-agent.ts b/examples/nodejs/baseai/pipes/perplexity-agent.ts
new file mode 100644
index 00000000..c09a0280
--- /dev/null
+++ b/examples/nodejs/baseai/pipes/perplexity-agent.ts
@@ -0,0 +1,32 @@
+import {PipeI} from '@baseai/core';
+
+const perplexityAgentPipe = (): PipeI => ({
+ apiKey: process.env.LANGBASE_API_KEY!,
+ name: 'perplexity-agent',
+ description: 'Research with a Perplexity Agent API preset.',
+ status: 'private',
+ model: 'perplexity:fast',
+ stream: true,
+ json: false,
+ store: true,
+ moderate: true,
+ top_p: 1,
+ max_tokens: 1000,
+ temperature: 0.7,
+ presence_penalty: 1,
+ frequency_penalty: 1,
+ stop: [],
+ tool_choice: 'auto',
+ parallel_tool_calls: true,
+ messages: [
+ {
+ role: 'system',
+ content: 'Answer concisely and cite sources for factual claims.',
+ },
+ ],
+ variables: [],
+ memory: [],
+ tools: [],
+});
+
+export default perplexityAgentPipe;
diff --git a/examples/nodejs/examples/pipe.perplexity-agent.stream.ts b/examples/nodejs/examples/pipe.perplexity-agent.stream.ts
new file mode 100644
index 00000000..ab4fe0a0
--- /dev/null
+++ b/examples/nodejs/examples/pipe.perplexity-agent.stream.ts
@@ -0,0 +1,25 @@
+import 'dotenv/config';
+import {getRunner, Pipe} from '@baseai/core';
+import perplexityAgentPipe from '../baseai/pipes/perplexity-agent';
+
+const pipe = new Pipe(perplexityAgentPipe());
+
+async function main() {
+ const {stream} = await pipe.run({
+ messages: [
+ {
+ role: 'user',
+ content:
+ 'What changed in the latest JavaScript language release?',
+ },
+ ],
+ stream: true,
+ });
+
+ const runner = getRunner(stream);
+ runner.on('content', content => process.stdout.write(content));
+ runner.on('end', () => process.stdout.write('\n'));
+ runner.on('error', error => console.error(error));
+}
+
+main();
diff --git a/examples/nodejs/examples/pipe.perplexity-agent.ts b/examples/nodejs/examples/pipe.perplexity-agent.ts
new file mode 100644
index 00000000..49e12dfc
--- /dev/null
+++ b/examples/nodejs/examples/pipe.perplexity-agent.ts
@@ -0,0 +1,22 @@
+import 'dotenv/config';
+import {Pipe} from '@baseai/core';
+import perplexityAgentPipe from '../baseai/pipes/perplexity-agent';
+
+const pipe = new Pipe(perplexityAgentPipe());
+
+async function main() {
+ const response = await pipe.run({
+ messages: [
+ {
+ role: 'user',
+ content:
+ 'What changed in the latest JavaScript language release?',
+ },
+ ],
+ });
+
+ console.log(response.completion);
+ console.log(response.choices[0]?.message.citationMetadata);
+}
+
+main();
diff --git a/examples/nodejs/package.json b/examples/nodejs/package.json
index 5be76560..6d70436e 100644
--- a/examples/nodejs/package.json
+++ b/examples/nodejs/package.json
@@ -7,21 +7,27 @@
"main": "index.js",
"scripts": {
"baseai": "baseai",
+ "check": "pnpm --filter baseai build && pnpm type-check && baseai --help",
"pipe.run": "npx tsx ./examples/pipe.run.ts",
"pipe.run.stream": "npx tsx ./examples/pipe.run.stream.ts",
"pipe.run.stream.loop": "npx tsx ./examples/pipe.run.stream.loop.ts",
+ "pipe.perplexity.agent": "npx tsx ./examples/pipe.perplexity-agent.ts",
+ "pipe.perplexity.agent.stream": "npx tsx ./examples/pipe.perplexity-agent.stream.ts",
"pipe.generate.text": "npx tsx ./examples/pipe.generate.text.ts",
- "pipe.stream.text": "npx tsx ./examples/pipe.stream.text.ts"
+ "pipe.stream.text": "npx tsx ./examples/pipe.stream.text.ts",
+ "type-check": "pnpm --filter @baseai/core build && tsc --noEmit"
},
"keywords": [],
"author": "Ahmad Awais (https://twitter.com/MrAhmadAwais)",
"license": "UNLICENSED",
"dependencies": {
- "@baseai/core": "^0.9.43",
+ "@baseai/core": "workspace:*",
"dotenv": "^16.4.5"
},
"devDependencies": {
- "baseai": "^0.9.44",
- "tsx": "^4.19.0"
+ "@types/node": "^22.6.1",
+ "baseai": "workspace:*",
+ "tsx": "^4.19.0",
+ "typescript": "^5.6.2"
}
}
diff --git a/examples/nodejs/readme.md b/examples/nodejs/readme.md
index 93fc48f6..82fa162a 100644
--- a/examples/nodejs/readme.md
+++ b/examples/nodejs/readme.md
@@ -4,6 +4,58 @@ BaseAI Node.js examples.
Please read the [documentation](https://baseai.dev/docs) for more information.
+## Perplexity Agent API migration example
+
+Sonar Chat Completions is now [**Agent API**](https://docs.perplexity.ai/docs/agent-api/quickstart). Migrate by September 27, 2026. View the [**Migration Guide**](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview).
+
+The four opt-in local BaseAI Agent aliases are `perplexity:fast` for quick lookups, `perplexity:low` for everyday research, `perplexity:medium` for multi-hop research, and `perplexity:high` for deep research. Choose by workload; do not assume an exact mapping from these historical BaseAI IDs:
+
+- `perplexity:llama-3.1-sonar-huge-128k-online`
+- `perplexity:llama-3.1-sonar-large-128k-online`
+- `perplexity:llama-3.1-sonar-small-128k-online`
+- `perplexity:llama-3.1-sonar-large-128k-chat`
+- `perplexity:llama-3.1-sonar-small-128k-chat`
+
+Existing Pipes are not migrated automatically. Change only the model ID and review the required sampling fields:
+
+```diff
+-model: 'perplexity:llama-3.1-sonar-small-128k-online',
++model: 'perplexity:fast',
+ max_tokens: 1000,
+ temperature: 0.7,
+ top_p: 1,
+```
+
+BaseAI sends `max_tokens` as Agent `max_output_tokens` and sends `temperature` and `top_p`; supplied values override preset defaults.
+
+Create `examples/nodejs/.env` with both server-side keys:
+
+```bash
+LANGBASE_API_KEY="your-langbase-key"
+PERPLEXITY_API_KEY="your-perplexity-key"
+```
+
+The local Core caller reads `PERPLEXITY_API_KEY` and passes it to the BaseAI server on `localhost:9000` for the provider request. `LANGBASE_API_KEY` is read by the Pipe configuration. Do not commit either value.
+
+From the repository root:
+
+```bash
+pnpm install
+pnpm --filter example-nodejs check
+pnpm --filter example-nodejs baseai dev
+```
+
+In another terminal, run the non-streaming example to print completion text and typed `citationMetadata`, or the separate streaming example to consume the public Core stream:
+
+```bash
+pnpm --filter example-nodejs pipe.perplexity.agent
+pnpm --filter example-nodejs pipe.perplexity.agent.stream
+```
+
+This is public BaseAI OSS local-runtime support only. It does not change, validate, or propose fixes for Langbase-hosted or other proprietary systems. Custom tools and tool replay, streamed citation metadata, background mode, file output, explicit Agent models, `xhigh`, and `wide-research` are unsupported. See the [migration overview](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview) and [field-by-field guide](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/how-to).
+
+## Other examples
+
```sh
# Install the dependencies
npm install
@@ -22,6 +74,8 @@ npx baseai pipe
npm run pipe.run
npm run pipe.run.stream
npm run pipe.run.stream.loop
+npm run pipe.perplexity.agent
+npm run pipe.perplexity.agent.stream
npm run pipe.generate.text
npm run pipe.stream.text
```
diff --git a/examples/nodejs/tsconfig.json b/examples/nodejs/tsconfig.json
new file mode 100644
index 00000000..d7ecca4c
--- /dev/null
+++ b/examples/nodejs/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022",
+ "types": ["node"]
+ },
+ "include": ["baseai/**/*.ts", "examples/**/*.ts"]
+}
diff --git a/packages/baseai/CHANGELOG.md b/packages/baseai/CHANGELOG.md
index d1b20baa..ab25a402 100644
--- a/packages/baseai/CHANGELOG.md
+++ b/packages/baseai/CHANGELOG.md
@@ -1,5 +1,17 @@
# baseai
+## Unreleased
+
+### Minor Changes
+
+- Add local Perplexity Agent API support with the `fast`, `low`, `medium`, and
+ `high` presets, including provider-local text streaming, citation mapping,
+ executing-model reporting, optional usage, and status-preserving errors.
+- Deprecate the five legacy `perplexity:llama-3.1-sonar-*` identifiers ahead
+ of their scheduled September 27, 2026 shutdown. Existing Pipes are not
+ migrated automatically; follow the [Perplexity Sonar migration
+ guide](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview).
+
## 0.9.44
### Patch Changes
diff --git a/packages/baseai/src/data/models.ts b/packages/baseai/src/data/models.ts
index d8c4e055..c325ac7b 100644
--- a/packages/baseai/src/data/models.ts
+++ b/packages/baseai/src/data/models.ts
@@ -96,9 +96,9 @@ export const OLLAMA: string = 'ollama';
interface Model {
id: string;
provider: string;
- promptCost: number;
- completionCost: number;
- requestCost?: number;
+ promptCost: number | null;
+ completionCost: number | null;
+ requestCost?: number | null;
toolSupport?: {
toolChoice: boolean;
parallelToolCalls: boolean;
@@ -577,6 +577,34 @@ export const modelsByProvider: ModelsByProviderInclCosts = {
}
],
[PERPLEXITY]: [
+ {
+ id: 'fast',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'low',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'medium',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'high',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
{
id: 'llama-3.1-sonar-huge-128k-online',
provider: PERPLEXITY,
diff --git a/packages/baseai/src/dev/data/models.ts b/packages/baseai/src/dev/data/models.ts
index b82060c5..e3ee0c28 100644
--- a/packages/baseai/src/dev/data/models.ts
+++ b/packages/baseai/src/dev/data/models.ts
@@ -16,9 +16,9 @@ export const X_AI: string = 'xAI';
interface Model {
id: string;
provider: string;
- promptCost: number;
- completionCost: number;
- requestCost?: number;
+ promptCost: number | null;
+ completionCost: number | null;
+ requestCost?: number | null;
toolSupport?: {
toolChoice: boolean;
parallelToolCalls: boolean;
@@ -497,6 +497,34 @@ export const modelsByProvider: ModelsByProviderInclCosts = {
}
],
[PERPLEXITY]: [
+ {
+ id: 'fast',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'low',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'medium',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
+ {
+ id: 'high',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ },
{
id: 'llama-3.1-sonar-huge-128k-online',
provider: PERPLEXITY,
diff --git a/packages/baseai/src/dev/llms/call-llm.ts b/packages/baseai/src/dev/llms/call-llm.ts
index dcbf9b74..f1da0801 100644
--- a/packages/baseai/src/dev/llms/call-llm.ts
+++ b/packages/baseai/src/dev/llms/call-llm.ts
@@ -158,7 +158,8 @@ export async function callLLM({
pipe,
messages,
llmApiKey,
- stream
+ stream,
+ paramsTools
});
}
diff --git a/packages/baseai/src/dev/llms/call-perplexity.ts b/packages/baseai/src/dev/llms/call-perplexity.ts
index 5be492d7..f86f1ff9 100644
--- a/packages/baseai/src/dev/llms/call-perplexity.ts
+++ b/packages/baseai/src/dev/llms/call-perplexity.ts
@@ -3,22 +3,40 @@ import transformToProviderRequest from '../utils/provider-handlers/transfrom-to-
import { handleProviderRequest } from '../utils/provider-handlers/provider-request-handler';
import { PERPLEXITY } from '../data/models';
+import {
+ callPerplexityAgent,
+ getPerplexityTransport
+} from '../providers/perplexity/agentResponse';
import { handleLlmError } from './utils';
+import { ApiError } from '../hono/errors';
import type { Message, Pipe } from 'types/pipe';
import type { ModelParams } from 'types/providers';
+import type { PipeTool } from 'types/tools';
export async function callPerplexity({
pipe,
messages,
llmApiKey,
- stream
+ stream,
+ paramsTools
}: {
pipe: Pipe;
llmApiKey: string;
stream: boolean;
messages: Message[];
+ paramsTools?: PipeTool[];
}) {
try {
+ if (getPerplexityTransport(pipe.model) === 'agentResponse') {
+ return await callPerplexityAgent({
+ pipe,
+ messages,
+ llmApiKey,
+ stream,
+ paramsTools
+ });
+ }
+
const modelParams = buildModelParams(pipe, stream, messages);
// Transform params according to provider's format
@@ -37,6 +55,7 @@ export async function callPerplexity({
transformedRequestParams
});
} catch (error: any) {
+ if (error instanceof ApiError) throw error;
handleLlmError({ error, provider: PERPLEXITY });
}
}
diff --git a/packages/baseai/src/dev/providers/perplexity/agentResponse.test.ts b/packages/baseai/src/dev/providers/perplexity/agentResponse.test.ts
new file mode 100644
index 00000000..e9b908d1
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/agentResponse.test.ts
@@ -0,0 +1,443 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { Pipe } from 'types/pipe';
+import type { ProviderMessage } from 'types/providers';
+import {
+ buildPerplexityAgentRequest,
+ callPerplexityAgent,
+ getPerplexityAgentURL,
+ transformPerplexityAgentResponse,
+ type PerplexityAgentResponse
+} from './agentResponse';
+import documentedAgentResponse from './fixtures/documented-agent-response.json';
+
+function scaffoldPipe(overrides: Partial = {}): Pipe {
+ return {
+ name: 'perplexity-agent',
+ description: 'Perplexity Agent API example',
+ status: 'private',
+ model: 'perplexity:fast',
+ stream: true,
+ json: false,
+ store: true,
+ moderate: true,
+ top_p: 1,
+ max_tokens: 1000,
+ temperature: 0.7,
+ presence_penalty: 1,
+ frequency_penalty: 1,
+ stop: [],
+ tool_choice: 'auto',
+ parallel_tool_calls: true,
+ messages: [],
+ variables: [],
+ memory: [],
+ tools: [],
+ ...overrides
+ };
+}
+
+const messages: ProviderMessage[] = [
+ { role: 'system', content: 'Be concise.' },
+ { role: 'user', content: 'What changed?' },
+ { role: 'assistant', content: 'I will check.' }
+];
+
+const completedResponse = documentedAgentResponse as PerplexityAgentResponse;
+
+describe('Perplexity Agent request adapter', () => {
+ it('maps a standard scaffold changed only to perplexity:fast', () => {
+ const request = buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(),
+ messages,
+ stream: true
+ });
+
+ expect(request).toEqual({
+ preset: 'fast',
+ input: [
+ { type: 'message', role: 'system', content: 'Be concise.' },
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What changed?'
+ },
+ {
+ type: 'message',
+ role: 'assistant',
+ content: 'I will check.'
+ }
+ ],
+ max_output_tokens: 1000,
+ stream: true,
+ temperature: 0.7,
+ top_p: 1
+ });
+ expect(request).not.toHaveProperty('model');
+ expect(request).not.toHaveProperty('store');
+ expect(request).not.toHaveProperty('moderate');
+ expect(request).not.toHaveProperty('tools');
+ });
+
+ it('preserves store and moderate as platform behavior without forwarding them', () => {
+ const request = buildPerplexityAgentRequest({
+ pipe: scaffoldPipe({ store: false, moderate: false }),
+ messages,
+ stream: false
+ });
+ expect(request).not.toHaveProperty('store');
+ expect(request).not.toHaveProperty('moderate');
+ });
+
+ it.each([
+ ['presence_penalty', { presence_penalty: 0 }],
+ ['frequency_penalty', { frequency_penalty: 0 }],
+ ['stop', { stop: ['END'] }],
+ ['json', { json: true }],
+ ['tool_choice', { tool_choice: 'required' }],
+ ['parallel_tool_calls', { parallel_tool_calls: false }],
+ ['max_tokens', { max_tokens: 0 }]
+ ])('rejects unsupported %s values by field name', (field, overrides) => {
+ expect(() =>
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(overrides as Partial),
+ messages,
+ stream: false
+ })
+ ).toThrow(field);
+ });
+
+ it('rejects Pipe tools and request tools', () => {
+ const tool = {
+ type: 'function',
+ function: { name: 'lookup', parameters: {} },
+ run: vi.fn()
+ };
+ expect(() =>
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe({ tools: [tool] as Pipe['tools'] }),
+ messages,
+ stream: false
+ })
+ ).toThrow('tools');
+ expect(() =>
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(),
+ messages,
+ stream: false,
+ paramsTools: [tool] as Pipe['tools']
+ })
+ ).toThrow('tools');
+ });
+
+ it.each([
+ [
+ 'messages[0].role',
+ { role: 'tool', content: 'result', tool_call_id: 'call_1' }
+ ],
+ [
+ 'messages[0].tool_calls',
+ { role: 'assistant', content: null, tool_calls: [] }
+ ],
+ [
+ 'messages[0].name',
+ { role: 'assistant', content: 'hello', name: 'named' }
+ ]
+ ])('rejects unsupported history at %s', (field, message) => {
+ expect(() =>
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(),
+ messages: [message as ProviderMessage],
+ stream: false
+ })
+ ).toThrow(field);
+ });
+
+ it('rejects non-string content by field path', () => {
+ expect(() =>
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(),
+ messages: [
+ {
+ role: 'user',
+ content: [{ type: 'file_url' }] as never
+ }
+ ],
+ stream: false
+ })
+ ).toThrow('messages[0].content');
+ });
+
+ it('rejects non-empty tools before network I/O', async () => {
+ const fetcher = vi.fn();
+ await expect(
+ callPerplexityAgent(
+ {
+ pipe: scaffoldPipe(),
+ messages,
+ llmApiKey: 'test-key',
+ stream: false,
+ paramsTools: [{ type: 'function' }] as never
+ },
+ { fetcher: fetcher as typeof fetch }
+ )
+ ).rejects.toThrow('tools');
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+});
+
+describe('Perplexity Agent HTTP adapter', () => {
+ it('joins Agent URLs with and without trailing slashes', () => {
+ expect(getPerplexityAgentURL('https://api.perplexity.ai')).toBe(
+ 'https://api.perplexity.ai/v1/agent'
+ );
+ expect(getPerplexityAgentURL('https://api.perplexity.ai/')).toBe(
+ 'https://api.perplexity.ai/v1/agent'
+ );
+ });
+
+ it('sends the exact method, URL, headers, preset, and request fields', async () => {
+ const fetcher = vi.fn(
+ async () =>
+ new Response(JSON.stringify(completedResponse), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+ );
+
+ await callPerplexityAgent(
+ {
+ pipe: scaffoldPipe(),
+ messages,
+ llmApiKey: 'test-key',
+ stream: false
+ },
+ {
+ baseURL: 'https://api.perplexity.ai/',
+ fetcher: fetcher as typeof fetch
+ }
+ );
+
+ expect(fetcher).toHaveBeenCalledOnce();
+ const [url, init] = fetcher.mock.calls[0] as unknown as [
+ string,
+ NonNullable[1]>
+ ];
+ expect(url).toBe('https://api.perplexity.ai/v1/agent');
+ expect(init.method).toBe('POST');
+ expect(init.headers).toEqual({
+ Authorization: 'Bearer test-key',
+ 'content-type': 'application/json',
+ accept: 'application/json'
+ });
+ expect(JSON.parse(init.body as string)).toEqual({
+ preset: 'fast',
+ input: [
+ { type: 'message', role: 'system', content: 'Be concise.' },
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What changed?'
+ },
+ {
+ type: 'message',
+ role: 'assistant',
+ content: 'I will check.'
+ }
+ ],
+ max_output_tokens: 1000,
+ stream: false,
+ temperature: 0.7,
+ top_p: 1
+ });
+ });
+
+ it('uses only safe Agent error fields', async () => {
+ const fetcher = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ error: {
+ message: 'Preset is invalid',
+ type: 'invalid_request',
+ private_prompt: 'must not be included'
+ }
+ }),
+ { status: 400 }
+ )
+ );
+
+ await expect(
+ callPerplexityAgent(
+ {
+ pipe: scaffoldPipe(),
+ messages,
+ llmApiKey: 'test-key',
+ stream: false
+ },
+ { fetcher: fetcher as typeof fetch }
+ )
+ ).rejects.toThrow('Preset is invalid');
+ });
+
+ it.each([
+ [400, 'BAD_REQUEST'],
+ [422, 'BAD_REQUEST'],
+ [401, 'UNAUTHORIZED'],
+ [403, 'FORBIDDEN'],
+ [404, 'NOT_FOUND'],
+ [429, 'RATE_LIMITED'],
+ [500, 'INTERNAL_SERVER_ERROR']
+ ] as const)('preserves HTTP %i as %s', async (status, code) => {
+ const fetcher = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({ error: { message: 'Provider error' } }),
+ { status }
+ )
+ );
+
+ await expect(
+ callPerplexityAgent(
+ {
+ pipe: scaffoldPipe(),
+ messages,
+ llmApiKey: 'test-key',
+ stream: false
+ },
+ { fetcher: fetcher as typeof fetch }
+ )
+ ).rejects.toMatchObject({ status, code });
+ });
+
+ it('distinguishes a transport failure from a provider bad request', async () => {
+ const fetcher = vi.fn(async () => {
+ throw new TypeError('private transport detail');
+ });
+
+ await expect(
+ callPerplexityAgent(
+ {
+ pipe: scaffoldPipe(),
+ messages,
+ llmApiKey: 'test-key',
+ stream: false
+ },
+ { fetcher: fetcher as unknown as typeof fetch }
+ )
+ ).rejects.toMatchObject({
+ status: 500,
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Unable to reach the Perplexity Agent API'
+ });
+ });
+});
+
+describe('Perplexity Agent response adapter', () => {
+ it('maps the complete BaseAI response envelope and citations', () => {
+ const content =
+ 'A model card documents intended use and limitations.[web:4]';
+ const markerStart = content.indexOf('[web:4]');
+ expect(transformPerplexityAgentResponse(completedResponse)).toEqual({
+ id: 'resp_docs_model_card',
+ object: 'chat.completion',
+ created: 1784292159,
+ model: 'openai/gpt-5.4-mini',
+ provider: 'Perplexity',
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content,
+ citationMetadata: {
+ citationSources: [
+ {
+ startIndex: markerStart,
+ endIndex: markerStart + '[web:4]'.length,
+ uri: 'https://arxiv.org/abs/1810.03993'
+ }
+ ]
+ }
+ },
+ index: 0,
+ logprobs: null,
+ finish_reason: 'stop'
+ }
+ ],
+ usage: {
+ prompt_tokens: 120,
+ completion_tokens: 12,
+ total_tokens: 132
+ }
+ });
+ });
+
+ it('omits usage when Agent usage is absent', () => {
+ const response = { ...completedResponse, usage: undefined };
+ expect(transformPerplexityAgentResponse(response)).not.toHaveProperty(
+ 'usage'
+ );
+ });
+
+ it('maps documented token exhaustion to length', () => {
+ const response: PerplexityAgentResponse = {
+ ...completedResponse,
+ status: 'incomplete',
+ incomplete_details: { reason: 'max_output_tokens' }
+ };
+ expect(
+ transformPerplexityAgentResponse(response).choices[0].finish_reason
+ ).toBe('length');
+ });
+
+ it('rejects undocumented incomplete reasons', () => {
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ status: 'incomplete',
+ incomplete_details: { reason: 'max_tokens' }
+ })
+ ).toThrow();
+ });
+
+ it.each([
+ ['incomplete', undefined],
+ ['failed', { message: 'upstream failed' }],
+ ['cancelled', undefined],
+ ['in_progress', undefined]
+ ])('rejects %s responses outside token exhaustion', (status, error) => {
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ status,
+ error
+ })
+ ).toThrow();
+ });
+
+ it('rejects malformed and empty completed responses', () => {
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ id: undefined
+ })
+ ).toThrow('missing identity');
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ output: []
+ })
+ ).toThrow('empty completed output');
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ model: undefined
+ })
+ ).toThrow('missing model');
+ expect(() =>
+ transformPerplexityAgentResponse({
+ ...completedResponse,
+ usage: { input_tokens: 1, output_tokens: 2 }
+ })
+ ).toThrow('malformed usage');
+ });
+});
diff --git a/packages/baseai/src/dev/providers/perplexity/agentResponse.ts b/packages/baseai/src/dev/providers/perplexity/agentResponse.ts
new file mode 100644
index 00000000..4d4ac497
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/agentResponse.ts
@@ -0,0 +1,828 @@
+import { PERPLEXITY } from '@/dev/data/models';
+import { ApiError } from '@/dev/hono/errors';
+import type { StatusCode } from 'hono/utils/http-status';
+import type { Pipe } from 'types/pipe';
+import type {
+ ChatCompletionResponse,
+ CitationSource,
+ ProviderMessage
+} from 'types/providers';
+import type { PipeTool } from 'types/tools';
+import PerplexityAIApiConfig from './api';
+import { iterateAgentSSE } from './agentSSE';
+
+export const PERPLEXITY_AGENT_PRESETS = [
+ 'fast',
+ 'low',
+ 'medium',
+ 'high'
+] as const;
+
+export type PerplexityAgentPreset = (typeof PERPLEXITY_AGENT_PRESETS)[number];
+
+interface AgentInputMessage {
+ type: 'message';
+ role: 'system' | 'user' | 'assistant';
+ content: string;
+}
+
+export interface PerplexityAgentRequest {
+ input: AgentInputMessage[];
+ max_output_tokens: number;
+ preset: PerplexityAgentPreset;
+ stream: boolean;
+ temperature: number;
+ top_p: number;
+}
+
+interface AgentAnnotation {
+ end_index?: number;
+ id?: number | string;
+ search_result_id?: number | string;
+ source_id?: number | string;
+ start_index?: number;
+ type?: string;
+ uri?: string;
+ url?: string;
+}
+
+interface AgentOutputText {
+ annotations?: AgentAnnotation[];
+ text: string;
+ type: 'output_text';
+}
+
+interface AgentMessageOutput {
+ content: AgentOutputText[];
+ id: string;
+ role: 'assistant';
+ status: string;
+ type: 'message';
+}
+
+interface AgentSearchResult {
+ id: number | string;
+ url: string;
+}
+
+interface AgentSearchResultsOutput {
+ results: AgentSearchResult[];
+ type: 'search_results';
+}
+
+type AgentOutputItem =
+ | AgentMessageOutput
+ | AgentSearchResultsOutput
+ | { type: string; [key: string]: unknown };
+
+interface AgentUsage {
+ input_tokens?: number;
+ output_tokens?: number;
+ total_tokens?: number;
+}
+
+interface AgentErrorInfo {
+ code?: string;
+ message?: string;
+ type?: string;
+}
+
+export interface PerplexityAgentResponse {
+ created_at?: number;
+ error?: AgentErrorInfo | null;
+ id?: string;
+ incomplete_details?: {
+ reason?: string;
+ } | null;
+ model?: string;
+ object?: string;
+ output?: AgentOutputItem[];
+ status?: string;
+ usage?: AgentUsage;
+}
+
+interface AgentStreamEvent {
+ content_index?: number;
+ delta?: string;
+ error?: AgentErrorInfo;
+ item_id?: string;
+ output_index?: number;
+ response?: PerplexityAgentResponse;
+ sequence_number?: number;
+ type?: string;
+}
+
+interface AgentCallOptions {
+ baseURL?: string;
+ fetcher?: typeof fetch;
+}
+
+const AGENT_OBJECT = 'chat.completion';
+const AGENT_STREAM_OBJECT = 'chat.completion.chunk';
+
+export function isPerplexityAgentModel(model: string): boolean {
+ const preset = model.startsWith('perplexity:')
+ ? model.slice('perplexity:'.length)
+ : model;
+ return PERPLEXITY_AGENT_PRESETS.includes(preset as PerplexityAgentPreset);
+}
+
+export function getPerplexityTransport(
+ model: string
+): 'agentResponse' | 'chatComplete' {
+ return isPerplexityAgentModel(model) ? 'agentResponse' : 'chatComplete';
+}
+
+function getAgentPreset(model: string): PerplexityAgentPreset {
+ const preset = model.startsWith('perplexity:')
+ ? model.slice('perplexity:'.length)
+ : model;
+ if (!isPerplexityAgentModel(preset)) {
+ throw new ApiError({
+ code: 'BAD_REQUEST',
+ message: `Unsupported Perplexity Agent preset: ${model}`
+ });
+ }
+ return preset as PerplexityAgentPreset;
+}
+
+function unsupportedField(field: string, detail: string): never {
+ throw new ApiError({
+ code: 'BAD_REQUEST',
+ message: `Perplexity Agent API field "${field}" is unsupported: ${detail}`
+ });
+}
+
+function validateAgentPipeFields(
+ pipe: Pipe,
+ paramsTools: PipeTool[] | undefined
+) {
+ if (pipe.presence_penalty !== 1) {
+ unsupportedField(
+ 'presence_penalty',
+ 'only the BaseAI scaffold default of 1 can be omitted'
+ );
+ }
+ if (pipe.frequency_penalty !== 1) {
+ unsupportedField(
+ 'frequency_penalty',
+ 'only the BaseAI scaffold default of 1 can be omitted'
+ );
+ }
+ if (pipe.stop.length > 0) {
+ unsupportedField('stop', 'non-empty stop sequences are not supported');
+ }
+ if (pipe.json) {
+ unsupportedField('json', 'structured output is outside this release');
+ }
+
+ const hasPipeTools = pipe.tools.length > 0;
+ const hasParamTools = Boolean(paramsTools?.length);
+ if (hasPipeTools || hasParamTools) {
+ unsupportedField(
+ 'tools',
+ 'custom tools cannot preserve Agent thought signatures yet'
+ );
+ }
+ if (pipe.tool_choice !== 'auto') {
+ unsupportedField(
+ 'tool_choice',
+ 'only the BaseAI scaffold default of "auto" can be omitted'
+ );
+ }
+ if (pipe.parallel_tool_calls !== true) {
+ unsupportedField(
+ 'parallel_tool_calls',
+ 'only the BaseAI scaffold default of true can be omitted'
+ );
+ }
+ if (pipe.max_tokens < 1) {
+ unsupportedField('max_tokens', 'the value must be at least 1');
+ }
+}
+
+function convertMessage(
+ message: ProviderMessage,
+ messageIndex: number
+): AgentInputMessage {
+ const path = `messages[${messageIndex}]`;
+ if (!['system', 'user', 'assistant'].includes(message.role)) {
+ unsupportedField(
+ `${path}.role`,
+ `role "${message.role}" cannot be replayed`
+ );
+ }
+ if (message.function_call !== undefined) {
+ unsupportedField(
+ `${path}.function_call`,
+ 'function history is unsupported'
+ );
+ }
+ if (message.tool_calls !== undefined) {
+ unsupportedField(
+ `${path}.tool_calls`,
+ 'tool-call history is unsupported'
+ );
+ }
+ if (message.tool_call_id !== undefined) {
+ unsupportedField(`${path}.tool_call_id`, 'tool history is unsupported');
+ }
+ if (message.name !== undefined) {
+ unsupportedField(`${path}.name`, 'named messages are unsupported');
+ }
+ if (typeof message.content !== 'string') {
+ unsupportedField(`${path}.content`, 'message content must be a string');
+ }
+
+ return {
+ type: 'message',
+ role: message.role as AgentInputMessage['role'],
+ content: message.content
+ };
+}
+
+export function buildPerplexityAgentRequest({
+ pipe,
+ messages,
+ stream,
+ paramsTools
+}: {
+ pipe: Pipe;
+ messages: ProviderMessage[];
+ stream: boolean;
+ paramsTools?: PipeTool[];
+}): PerplexityAgentRequest {
+ validateAgentPipeFields(pipe, paramsTools);
+
+ return {
+ preset: getAgentPreset(pipe.model),
+ input: messages.map(convertMessage),
+ max_output_tokens: pipe.max_tokens,
+ stream,
+ temperature: pipe.temperature,
+ top_p: pipe.top_p
+ };
+}
+
+export function getPerplexityAgentURL(
+ baseURL: string,
+ endpoint = PerplexityAIApiConfig.agentResponse || '/v1/agent'
+): string {
+ return `${baseURL.replace(/\/+$/, '')}/${endpoint.replace(/^\/+/, '')}`;
+}
+
+function getSearchResultURLs(output: AgentOutputItem[]): Map {
+ const urls = new Map();
+ for (const item of output) {
+ if (item.type !== 'search_results') continue;
+ for (const result of (item as AgentSearchResultsOutput).results || []) {
+ if (result.url) urls.set(String(result.id), result.url);
+ }
+ }
+ return urls;
+}
+
+function citationIdFromMarker(marker: string): string | undefined {
+ return marker.match(/^\[(?:web:)?(\d+)\]$/i)?.[1];
+}
+
+function extractTextAndCitations(output: AgentOutputItem[]): {
+ text: string;
+ citations: CitationSource[];
+} {
+ const searchResultURLs = getSearchResultURLs(output);
+ const textParts: string[] = [];
+ const citations: CitationSource[] = [];
+ const seenCitations = new Set();
+ let textOffset = 0;
+
+ const addCitation = (citation: CitationSource) => {
+ const key = `${citation.startIndex ?? ''}:${citation.endIndex ?? ''}:${citation.uri ?? ''}`;
+ if (seenCitations.has(key)) return;
+ seenCitations.add(key);
+ citations.push(citation);
+ };
+
+ for (const item of output) {
+ if (item.type !== 'message') continue;
+ const message = item as AgentMessageOutput;
+ if (message.role !== 'assistant') {
+ throw new Error(
+ 'Invalid Perplexity Agent response: non-assistant output'
+ );
+ }
+
+ for (const part of message.content || []) {
+ if (part.type !== 'output_text' || typeof part.text !== 'string') {
+ throw new Error(
+ 'Invalid Perplexity Agent response: malformed output text'
+ );
+ }
+ textParts.push(part.text);
+
+ for (const annotation of part.annotations || []) {
+ const start = annotation.start_index;
+ const end = annotation.end_index;
+ const marker =
+ typeof start === 'number' && typeof end === 'number'
+ ? part.text.slice(start, end)
+ : '';
+ const resultId =
+ annotation.search_result_id ??
+ annotation.source_id ??
+ annotation.id ??
+ citationIdFromMarker(marker);
+ const uri =
+ annotation.url ??
+ annotation.uri ??
+ (resultId === undefined
+ ? undefined
+ : searchResultURLs.get(String(resultId)));
+
+ if (!uri) continue;
+ addCitation({
+ startIndex:
+ typeof start === 'number'
+ ? textOffset + start
+ : undefined,
+ endIndex:
+ typeof end === 'number' ? textOffset + end : undefined,
+ uri
+ });
+ }
+
+ const markerPattern = /\[(?:web:)?(\d+)\]/gi;
+ for (const marker of part.text.matchAll(markerPattern)) {
+ const uri = searchResultURLs.get(marker[1]);
+ if (!uri || marker.index === undefined) continue;
+ addCitation({
+ startIndex: textOffset + marker.index,
+ endIndex: textOffset + marker.index + marker[0].length,
+ uri
+ });
+ }
+
+ textOffset += part.text.length;
+ }
+ }
+
+ return { text: textParts.join(''), citations };
+}
+
+function responseFailureMessage(response: PerplexityAgentResponse): string {
+ return (
+ response.error?.message ||
+ `Perplexity Agent response ended with status "${response.status || 'unknown'}"`
+ );
+}
+
+export function transformPerplexityAgentResponse(
+ response: PerplexityAgentResponse
+): ChatCompletionResponse & { provider: string } {
+ const status = response.status;
+ const isTokenLimit =
+ status === 'incomplete' &&
+ response.incomplete_details?.reason === 'max_output_tokens';
+ if (status !== 'completed' && !isTokenLimit) {
+ throw new Error(responseFailureMessage(response));
+ }
+ if (!response.id || typeof response.created_at !== 'number') {
+ throw new Error('Invalid Perplexity Agent response: missing identity');
+ }
+ if (!response.model || typeof response.model !== 'string') {
+ throw new Error('Invalid Perplexity Agent response: missing model');
+ }
+ if (!Array.isArray(response.output)) {
+ throw new Error('Invalid Perplexity Agent response: missing output');
+ }
+
+ const { text, citations } = extractTextAndCitations(response.output);
+ if (!text) {
+ throw new Error(
+ 'Invalid Perplexity Agent response: empty completed output'
+ );
+ }
+
+ let usage: ChatCompletionResponse['usage'];
+ if (response.usage !== undefined) {
+ const { input_tokens, output_tokens, total_tokens } = response.usage;
+ if (
+ ![input_tokens, output_tokens, total_tokens].every(
+ tokens => Number.isInteger(tokens) && (tokens as number) >= 0
+ )
+ ) {
+ throw new Error(
+ 'Invalid Perplexity Agent response: malformed usage'
+ );
+ }
+ usage = {
+ prompt_tokens: input_tokens as number,
+ completion_tokens: output_tokens as number,
+ total_tokens: total_tokens as number
+ };
+ }
+
+ return {
+ id: response.id,
+ object: AGENT_OBJECT,
+ created: response.created_at,
+ model: response.model,
+ provider: PERPLEXITY,
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content: text,
+ ...(citations.length > 0 && {
+ citationMetadata: { citationSources: citations }
+ })
+ },
+ index: 0,
+ logprobs: null,
+ finish_reason: isTokenLimit ? 'length' : 'stop'
+ }
+ ],
+ ...(usage && { usage })
+ };
+}
+
+function streamError(event: AgentStreamEvent): Error {
+ const message =
+ event.error?.message ||
+ event.response?.error?.message ||
+ `Perplexity Agent stream ended with event "${event.type || 'unknown'}"`;
+ return new Error(message);
+}
+
+function makeStreamChunk({
+ id,
+ created,
+ model,
+ delta,
+ finishReason
+}: {
+ id: string;
+ created: number;
+ model: string;
+ delta: Record;
+ finishReason: string | null;
+}) {
+ return `data: ${JSON.stringify({
+ id,
+ object: AGENT_STREAM_OBJECT,
+ created,
+ model,
+ provider: PERPLEXITY,
+ choices: [
+ {
+ delta,
+ index: 0,
+ finish_reason: finishReason
+ }
+ ]
+ })}\n\n`;
+}
+
+export function transformPerplexityAgentStream(
+ response: Response,
+ abortController = new AbortController()
+): ReadableStream {
+ const events = iterateAgentSSE(response, abortController);
+ const encoder = new TextEncoder();
+
+ return new ReadableStream({
+ async start(controller) {
+ let responseId: string | undefined;
+ let createdAt: number | undefined;
+ let executingModel: string | undefined;
+ let lastSequence = -1;
+ let terminalSeen = false;
+ let roleEmitted = false;
+ let textSeen = false;
+ let lastOutputIndex = -1;
+ let lastContentIndex = -1;
+ const itemByPosition = new Map();
+
+ try {
+ for await (const rawEvent of events) {
+ if (
+ !rawEvent ||
+ typeof rawEvent !== 'object' ||
+ Array.isArray(rawEvent)
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream event is malformed'
+ );
+ }
+ const event = rawEvent;
+ if (typeof event.type !== 'string') {
+ throw new Error(
+ 'Perplexity Agent stream event is malformed'
+ );
+ }
+ if (terminalSeen) {
+ throw new Error(
+ 'Perplexity Agent stream emitted data after a terminal event'
+ );
+ }
+
+ if (
+ !Number.isInteger(event.sequence_number) ||
+ (event.sequence_number as number) < 0
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream sequence is malformed'
+ );
+ }
+ if ((event.sequence_number as number) <= lastSequence) {
+ throw new Error(
+ 'Perplexity Agent stream sequence is duplicate or out of order'
+ );
+ }
+ lastSequence = event.sequence_number as number;
+
+ if (
+ event.type === 'response.created' ||
+ event.type === 'response.in_progress'
+ ) {
+ const id = event.response?.id;
+ const created = event.response?.created_at;
+ const model = event.response?.model;
+ if (
+ !id ||
+ typeof created !== 'number' ||
+ !model ||
+ typeof model !== 'string'
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream identity event is malformed'
+ );
+ }
+ if (responseId && responseId !== id) {
+ throw new Error(
+ 'Perplexity Agent stream response identity changed'
+ );
+ }
+ if (createdAt !== undefined && createdAt !== created) {
+ throw new Error(
+ 'Perplexity Agent stream response identity changed'
+ );
+ }
+ if (executingModel && executingModel !== model) {
+ throw new Error(
+ 'Perplexity Agent stream response identity changed'
+ );
+ }
+ responseId = id;
+ createdAt = created;
+ executingModel = model;
+ continue;
+ }
+
+ if (event.type === 'response.output_text.delta') {
+ if (
+ !responseId ||
+ createdAt === undefined ||
+ !executingModel
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream emitted text before identity'
+ );
+ }
+ if (
+ typeof event.delta !== 'string' ||
+ typeof event.item_id !== 'string' ||
+ typeof event.output_index !== 'number' ||
+ typeof event.content_index !== 'number'
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream text delta is malformed'
+ );
+ }
+
+ const isEarlierPosition =
+ event.output_index < lastOutputIndex ||
+ (event.output_index === lastOutputIndex &&
+ event.content_index < lastContentIndex);
+ if (isEarlierPosition) {
+ throw new Error(
+ 'Perplexity Agent stream text indexes are out of order'
+ );
+ }
+
+ const position = `${event.output_index}:${event.content_index}`;
+ const knownItem = itemByPosition.get(position);
+ if (knownItem && knownItem !== event.item_id) {
+ throw new Error(
+ 'Perplexity Agent stream item identity changed'
+ );
+ }
+ itemByPosition.set(position, event.item_id);
+ lastOutputIndex = event.output_index;
+ lastContentIndex = event.content_index;
+
+ if (!roleEmitted) {
+ controller.enqueue(
+ encoder.encode(
+ makeStreamChunk({
+ id: responseId,
+ created: createdAt,
+ model: executingModel,
+ delta: {
+ role: 'assistant',
+ content: ''
+ },
+ finishReason: null
+ })
+ )
+ );
+ roleEmitted = true;
+ }
+
+ controller.enqueue(
+ encoder.encode(
+ makeStreamChunk({
+ id: responseId,
+ created: createdAt,
+ model: executingModel,
+ delta: { content: event.delta },
+ finishReason: null
+ })
+ )
+ );
+ textSeen ||= event.delta.length > 0;
+ continue;
+ }
+
+ if (event.type === 'response.failed') {
+ terminalSeen = true;
+ throw streamError(event);
+ }
+
+ if (event.type === 'response.completed') {
+ terminalSeen = true;
+ if (
+ event.response?.status !== 'completed' ||
+ !responseId ||
+ createdAt === undefined ||
+ event.response.id !== responseId ||
+ event.response.created_at !== createdAt ||
+ event.response.model !== executingModel
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream completion event is malformed'
+ );
+ }
+ continue;
+ }
+ }
+
+ if (abortController.signal.aborted) return;
+ if (!terminalSeen) {
+ throw new Error(
+ 'Perplexity Agent stream ended before response.completed'
+ );
+ }
+ if (
+ !textSeen ||
+ !responseId ||
+ createdAt === undefined ||
+ !executingModel
+ ) {
+ throw new Error(
+ 'Perplexity Agent stream completed without output text'
+ );
+ }
+
+ controller.enqueue(
+ encoder.encode(
+ makeStreamChunk({
+ id: responseId,
+ created: createdAt,
+ model: executingModel,
+ delta: {},
+ finishReason: 'stop'
+ })
+ )
+ );
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ controller.close();
+ } catch (error) {
+ abortController.abort();
+ controller.error(
+ error instanceof Error
+ ? error
+ : new Error('Perplexity Agent stream failed')
+ );
+ }
+ },
+ cancel() {
+ abortController.abort();
+ }
+ });
+}
+
+async function getSafeAgentError(response: Response): Promise {
+ try {
+ const body = (await response.json()) as { error?: unknown };
+ if (
+ body.error &&
+ typeof body.error === 'object' &&
+ 'message' in body.error &&
+ typeof body.error.message === 'string'
+ ) {
+ return body.error.message;
+ }
+ return `HTTP ${response.status}`;
+ } catch {
+ return `HTTP ${response.status}`;
+ }
+}
+
+function getAgentHttpError(status: number): {
+ code:
+ | 'BAD_REQUEST'
+ | 'UNAUTHORIZED'
+ | 'FORBIDDEN'
+ | 'NOT_FOUND'
+ | 'RATE_LIMITED'
+ | 'INTERNAL_SERVER_ERROR';
+ status: StatusCode;
+} {
+ if (status === 400 || status === 422) {
+ return { code: 'BAD_REQUEST', status: status as StatusCode };
+ }
+ if (status === 401) return { code: 'UNAUTHORIZED', status: 401 };
+ if (status === 403) return { code: 'FORBIDDEN', status: 403 };
+ if (status === 404) return { code: 'NOT_FOUND', status: 404 };
+ if (status === 429) return { code: 'RATE_LIMITED', status: 429 };
+ if (status >= 500 && status <= 599) {
+ return { code: 'INTERNAL_SERVER_ERROR', status: status as StatusCode };
+ }
+ return { code: 'BAD_REQUEST', status: status as StatusCode };
+}
+
+export async function callPerplexityAgent(
+ {
+ pipe,
+ messages,
+ llmApiKey,
+ stream,
+ paramsTools
+ }: {
+ pipe: Pipe;
+ messages: ProviderMessage[];
+ llmApiKey: string;
+ stream: boolean;
+ paramsTools?: PipeTool[];
+ },
+ options: AgentCallOptions = {}
+) {
+ const request = buildPerplexityAgentRequest({
+ pipe,
+ messages,
+ stream,
+ paramsTools
+ });
+ const baseURL =
+ options.baseURL ||
+ PerplexityAIApiConfig.baseURL ||
+ 'https://api.perplexity.ai';
+ const url = getPerplexityAgentURL(baseURL);
+ const abortController = new AbortController();
+ const fetcher = options.fetcher || fetch;
+ let response: Response;
+ try {
+ response = await fetcher(url, {
+ method: 'POST',
+ headers: PerplexityAIApiConfig.headers(llmApiKey),
+ body: JSON.stringify(request),
+ signal: abortController.signal
+ });
+ } catch {
+ throw new ApiError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Unable to reach the Perplexity Agent API'
+ });
+ }
+
+ if (!response.ok) {
+ const mapped = getAgentHttpError(response.status);
+ throw new ApiError({
+ ...mapped,
+ message: await getSafeAgentError(response)
+ });
+ }
+
+ if (stream) {
+ return transformPerplexityAgentStream(response, abortController);
+ }
+
+ let body: PerplexityAgentResponse;
+ try {
+ body = (await response.json()) as PerplexityAgentResponse;
+ } catch {
+ throw new Error('Invalid Perplexity Agent response: malformed JSON');
+ }
+ return transformPerplexityAgentResponse(body);
+}
diff --git a/packages/baseai/src/dev/providers/perplexity/agentSSE.ts b/packages/baseai/src/dev/providers/perplexity/agentSSE.ts
new file mode 100644
index 00000000..b7973dc7
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/agentSSE.ts
@@ -0,0 +1,110 @@
+interface SSEMessage {
+ event: string | null;
+ data: string;
+}
+
+function findEventBoundary(
+ buffer: string
+): { index: number; length: number } | null {
+ const match = /\r\n\r\n|\n\n|\r\r/.exec(buffer);
+ return match ? { index: match.index, length: match[0].length } : null;
+}
+
+function parseSSEMessage(block: string): SSEMessage | null {
+ let event: string | null = null;
+ const data: string[] = [];
+
+ for (const line of block.split(/\r\n|\n|\r/)) {
+ if (!line || line.startsWith(':')) continue;
+ const separator = line.indexOf(':');
+ const field = separator === -1 ? line : line.slice(0, separator);
+ const value =
+ separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
+
+ if (field === 'event') event = value;
+ if (field === 'data') data.push(value);
+ }
+
+ return data.length ? { event, data: data.join('\n') } : null;
+}
+
+function parseAgentEvent(message: SSEMessage): T {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(message.data);
+ } catch {
+ throw new Error('Could not parse Perplexity Agent SSE message as JSON');
+ }
+
+ if (
+ message.event &&
+ parsed &&
+ typeof parsed === 'object' &&
+ !Array.isArray(parsed) &&
+ !('type' in parsed)
+ ) {
+ return { ...parsed, type: message.event } as T;
+ }
+ return parsed as T;
+}
+
+export async function* iterateAgentSSE(
+ response: Response,
+ abortController: AbortController
+): AsyncGenerator {
+ if (!response.body) {
+ throw new Error('Perplexity Agent stream response has no body');
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+ let doneMarkerSeen = false;
+ const cancelReader = () => {
+ void reader.cancel().catch(() => undefined);
+ };
+ abortController.signal.addEventListener('abort', cancelReader, {
+ once: true
+ });
+
+ const consumeBlock = (block: string): T | undefined => {
+ const message = parseSSEMessage(block);
+ if (!message) return;
+ if (doneMarkerSeen) {
+ throw new Error(
+ 'Perplexity Agent stream emitted data after the [DONE] marker'
+ );
+ }
+ if (message.data.trim() === '[DONE]') {
+ doneMarkerSeen = true;
+ return;
+ }
+ return parseAgentEvent(message);
+ };
+
+ try {
+ while (!abortController.signal.aborted) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ let boundary = findEventBoundary(buffer);
+ while (boundary) {
+ const block = buffer.slice(0, boundary.index);
+ buffer = buffer.slice(boundary.index + boundary.length);
+ const event = consumeBlock(block);
+ if (event !== undefined) yield event;
+ boundary = findEventBoundary(buffer);
+ }
+ }
+
+ buffer += decoder.decode();
+ if (buffer.trim()) {
+ const event = consumeBlock(buffer);
+ if (event !== undefined) yield event;
+ }
+ } finally {
+ abortController.signal.removeEventListener('abort', cancelReader);
+ reader.releaseLock();
+ }
+}
diff --git a/packages/baseai/src/dev/providers/perplexity/agentStream.test.ts b/packages/baseai/src/dev/providers/perplexity/agentStream.test.ts
new file mode 100644
index 00000000..1808ba93
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/agentStream.test.ts
@@ -0,0 +1,304 @@
+import { describe, expect, it, vi } from 'vitest';
+import { transformPerplexityAgentStream } from './agentResponse';
+import documentedAgentStream from './fixtures/documented-agent-stream.json';
+
+const created = {
+ type: 'response.created',
+ sequence_number: 0,
+ response: {
+ id: 'resp_stream_123',
+ created_at: 1787928000,
+ status: 'in_progress',
+ model: 'openai/gpt-5.4-mini'
+ }
+};
+
+const delta = (text: string, sequenceNumber = 1) => ({
+ type: 'response.output_text.delta',
+ sequence_number: sequenceNumber,
+ item_id: 'msg_stream_123',
+ output_index: 0,
+ content_index: 0,
+ delta: text
+});
+
+const completed = (sequenceNumber = 2) => ({
+ type: 'response.completed',
+ sequence_number: sequenceNumber,
+ response: {
+ id: 'resp_stream_123',
+ created_at: 1787928000,
+ status: 'completed',
+ model: 'openai/gpt-5.4-mini'
+ }
+});
+
+function sse(
+ events: object[],
+ lineEnding: '\n' | '\r\n' = '\n',
+ includeDone = true
+): string {
+ const body = events
+ .map(
+ event => `data: ${JSON.stringify(event)}${lineEnding}${lineEnding}`
+ )
+ .join('');
+ return includeDone ? `${body}data: [DONE]${lineEnding}${lineEnding}` : body;
+}
+
+function responseFromChunks(chunks: Uint8Array[]): Response {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(chunk);
+ controller.close();
+ }
+ }),
+ { status: 200, headers: { 'content-type': 'text/event-stream' } }
+ );
+}
+
+function responseFromText(text: string): Response {
+ return responseFromChunks([new TextEncoder().encode(text)]);
+}
+
+async function readText(stream: ReadableStream): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let text = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) return text + decoder.decode();
+ text += decoder.decode(value, { stream: true });
+ }
+}
+
+describe('Perplexity Agent stream reducer', () => {
+ it.each(['\n', '\r\n'] as const)(
+ 'accepts %j SSE delimiters and emits one terminal and one DONE',
+ async lineEnding => {
+ const output = await readText(
+ transformPerplexityAgentStream(
+ responseFromText(sse(documentedAgentStream, lineEnding))
+ )
+ );
+
+ const frames = output
+ .split('\n\n')
+ .filter(Boolean)
+ .map(frame => frame.replace(/^data: /, ''));
+ expect(frames).toHaveLength(4);
+ expect(JSON.parse(frames[0]).choices[0]).toEqual({
+ delta: { role: 'assistant', content: '' },
+ index: 0,
+ finish_reason: null
+ });
+ expect(JSON.parse(frames[1]).choices[0].delta).toEqual({
+ content: 'A model card'
+ });
+ expect(JSON.parse(frames[2]).choices[0]).toEqual({
+ delta: {},
+ index: 0,
+ finish_reason: 'stop'
+ });
+ expect(frames[3]).toBe('[DONE]');
+ for (const frame of frames.slice(0, 3)) {
+ expect(JSON.parse(frame).model).toBe('openai/gpt-5.4-mini');
+ }
+ expect(output.match(/finish_reason":"stop"/g)).toHaveLength(1);
+ expect(output.match(/data: \[DONE\]/g)).toHaveLength(1);
+ }
+ );
+
+ it('preserves fragmented UTF-8 text', async () => {
+ const source = new TextEncoder().encode(
+ sse([created, delta('café 🛰️'), completed()])
+ );
+ const splitAt = source.findIndex(byte => byte === 0xf0);
+ const response = responseFromChunks([
+ source.slice(0, splitAt + 1),
+ source.slice(splitAt + 1, splitAt + 3),
+ source.slice(splitAt + 3)
+ ]);
+
+ const output = await readText(transformPerplexityAgentStream(response));
+ expect(output).toContain('café 🛰️');
+ expect(output).not.toContain('�');
+ });
+
+ it('accepts multiline SSE data fields', async () => {
+ const createdJSON = JSON.stringify(created);
+ const splitAt = createdJSON.indexOf(',"response"') + 1;
+ const multilineCreated =
+ `data: ${createdJSON.slice(0, splitAt)}\n` +
+ `data: ${createdJSON.slice(splitAt)}\n\n`;
+ const response = responseFromText(
+ multilineCreated + sse([delta('Hello'), completed()])
+ );
+
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).resolves.toContain('Hello');
+ });
+
+ it('accepts named SSE events whose data omits type', async () => {
+ const withoutType = ({
+ type: _type,
+ ...event
+ }: T) => event;
+ const source =
+ `event: response.created\ndata: ${JSON.stringify(withoutType(created))}\n\n` +
+ `event: response.output_text.delta\ndata: ${JSON.stringify(
+ withoutType(delta('Named event'))
+ )}\n\n` +
+ `event: response.completed\ndata: ${JSON.stringify(
+ withoutType(completed())
+ )}\n\n` +
+ 'data: [DONE]\n\n';
+
+ await expect(
+ readText(transformPerplexityAgentStream(responseFromText(source)))
+ ).resolves.toContain('Named event');
+ });
+
+ it('redacts malformed SSE content from logging and errors', async () => {
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ const response = responseFromText(
+ 'data: {"private_prompt":"do not log"\n\n'
+ );
+
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow(
+ 'Could not parse Perplexity Agent SSE message as JSON'
+ );
+ expect(consoleError).not.toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it.each(['{}', '42'])(
+ 'rejects structurally malformed SSE data: %s',
+ async data => {
+ const response = responseFromText(`data: ${data}\n\n`);
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('stream event is malformed');
+ }
+ );
+
+ it('fails on response.failed without success terminal output', async () => {
+ const terminal = {
+ type: 'response.failed',
+ sequence_number: 1,
+ error: { message: 'upstream failed' }
+ };
+ const response = responseFromText(sse([created, terminal]));
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('upstream failed');
+ });
+
+ it('fails on duplicate terminal events', async () => {
+ const response = responseFromText(
+ sse([created, delta('Hello'), completed(), completed(3)])
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('after a terminal event');
+ });
+
+ it('fails on out-of-order terminal sequences', async () => {
+ const response = responseFromText(
+ sse([created, delta('Hello', 3), completed(2)])
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('duplicate or out of order');
+ });
+
+ it('requires a sequence number on every event', async () => {
+ const response = responseFromText(
+ sse([{ ...created, sequence_number: undefined }])
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('sequence is malformed');
+ });
+
+ it('fails on out-of-order output indexes', async () => {
+ const laterOutput = {
+ ...delta('later', 1),
+ output_index: 1,
+ item_id: 'msg_later'
+ };
+ const earlierOutput = {
+ ...delta('earlier', 2),
+ output_index: 0
+ };
+ const response = responseFromText(
+ sse([created, laterOutput, earlierOutput, completed(3)])
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('text indexes are out of order');
+ });
+
+ it('fails when response identity changes', async () => {
+ const changedIdentity = {
+ type: 'response.in_progress',
+ sequence_number: 1,
+ response: {
+ id: 'resp_stream_123',
+ created_at: 1787928001,
+ status: 'in_progress',
+ model: 'openai/gpt-5.4-mini'
+ }
+ };
+ const response = responseFromText(sse([created, changedIdentity]));
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('response identity changed');
+ });
+
+ it('fails when the executing model changes', async () => {
+ const changedModel = {
+ type: 'response.in_progress',
+ sequence_number: 1,
+ response: {
+ id: 'resp_stream_123',
+ created_at: 1787928000,
+ status: 'in_progress',
+ model: 'anthropic/claude-sonnet-4-6'
+ }
+ };
+ const response = responseFromText(sse([created, changedModel]));
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('response identity changed');
+ });
+
+ it('fails on premature EOF and premature DONE', async () => {
+ const prematureEOF = responseFromText(
+ sse([created, delta('partial')], '\n', false)
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(prematureEOF))
+ ).rejects.toThrow('before response.completed');
+
+ const prematureDone = responseFromText(
+ sse([created, delta('partial')])
+ );
+ await expect(
+ readText(transformPerplexityAgentStream(prematureDone))
+ ).rejects.toThrow('before response.completed');
+ });
+
+ it('fails when a completed stream contains no text', async () => {
+ const response = responseFromText(sse([created, completed(1)]));
+ await expect(
+ readText(transformPerplexityAgentStream(response))
+ ).rejects.toThrow('without output text');
+ });
+});
diff --git a/packages/baseai/src/dev/providers/perplexity/api.ts b/packages/baseai/src/dev/providers/perplexity/api.ts
index 59b5b849..7c8c125c 100644
--- a/packages/baseai/src/dev/providers/perplexity/api.ts
+++ b/packages/baseai/src/dev/providers/perplexity/api.ts
@@ -8,7 +8,8 @@ const PerplexityAIApiConfig: ProviderAPIConfig = {
accept: 'application/json'
};
},
- chatComplete: '/chat/completions'
+ chatComplete: '/chat/completions',
+ agentResponse: '/v1/agent'
};
export default PerplexityAIApiConfig;
diff --git a/packages/baseai/src/dev/providers/perplexity/catalog.node.test.ts b/packages/baseai/src/dev/providers/perplexity/catalog.node.test.ts
new file mode 100644
index 00000000..3c6ea45f
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/catalog.node.test.ts
@@ -0,0 +1,65 @@
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import {
+ PERPLEXITY as cliPerplexity,
+ modelsByProvider as cliModels
+} from '@/data/models';
+import {
+ PERPLEXITY as devPerplexity,
+ modelsByProvider as devModels
+} from '@/dev/data/models';
+import {
+ PERPLEXITY as corePerplexity,
+ modelsByProvider as coreModels
+} from '../../../../../core/src/data/models';
+
+const expectedIds = [
+ 'fast',
+ 'low',
+ 'medium',
+ 'high',
+ 'llama-3.1-sonar-huge-128k-online',
+ 'llama-3.1-sonar-large-128k-online',
+ 'llama-3.1-sonar-small-128k-online',
+ 'llama-3.1-sonar-large-128k-chat',
+ 'llama-3.1-sonar-small-128k-chat'
+];
+
+describe('Perplexity model catalogs', () => {
+ it('keeps all three catalog copies in parity', () => {
+ const catalogs = [
+ cliModels[cliPerplexity],
+ devModels[devPerplexity],
+ coreModels[corePerplexity]
+ ];
+ for (const catalog of catalogs) {
+ expect(catalog.map(model => model.id)).toEqual(expectedIds);
+ }
+ });
+
+ it('represents dynamic Agent preset pricing explicitly', () => {
+ for (const catalog of [
+ cliModels[cliPerplexity],
+ devModels[devPerplexity],
+ coreModels[corePerplexity]
+ ]) {
+ for (const model of catalog.slice(0, 4)) {
+ expect(model).toMatchObject({
+ promptCost: null,
+ completionCost: null,
+ requestCost: null
+ });
+ }
+ }
+ });
+
+ it('leaves the global Pipe scaffold default unchanged', async () => {
+ const file = fileURLToPath(
+ new URL('../../../pipe/index.ts', import.meta.url)
+ );
+ const source = await readFile(file, 'utf8');
+ expect(source).toContain("model: 'openai:gpt-4o-mini'");
+ expect(source).not.toContain("model: 'perplexity:fast'");
+ });
+});
diff --git a/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-response.json b/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-response.json
new file mode 100644
index 00000000..4c1fa407
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-response.json
@@ -0,0 +1,36 @@
+{
+ "id": "resp_docs_model_card",
+ "object": "response",
+ "created_at": 1784292159,
+ "status": "completed",
+ "model": "openai/gpt-5.4-mini",
+ "output": [
+ {
+ "type": "search_results",
+ "results": [
+ {
+ "id": 4,
+ "url": "https://arxiv.org/abs/1810.03993"
+ }
+ ]
+ },
+ {
+ "id": "msg_docs_model_card",
+ "content": [
+ {
+ "text": "A model card documents intended use and limitations.[web:4]",
+ "type": "output_text",
+ "annotations": []
+ }
+ ],
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "usage": {
+ "input_tokens": 120,
+ "output_tokens": 12,
+ "total_tokens": 132
+ }
+}
diff --git a/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-stream.json b/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-stream.json
new file mode 100644
index 00000000..0b453d1d
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/fixtures/documented-agent-stream.json
@@ -0,0 +1,60 @@
+[
+ {
+ "type": "response.created",
+ "sequence_number": 0,
+ "response": {
+ "id": "resp_docs_stream",
+ "object": "response",
+ "created_at": 1784292159,
+ "status": "in_progress",
+ "model": "openai/gpt-5.4-mini",
+ "output": []
+ }
+ },
+ {
+ "type": "response.output_item.added",
+ "sequence_number": 1,
+ "output_index": 0,
+ "item": {
+ "id": "msg_docs_stream",
+ "content": [],
+ "role": "assistant",
+ "status": "in_progress",
+ "type": "message"
+ }
+ },
+ {
+ "type": "response.output_text.delta",
+ "sequence_number": 2,
+ "item_id": "msg_docs_stream",
+ "output_index": 0,
+ "content_index": 0,
+ "delta": "A model card"
+ },
+ {
+ "type": "response.completed",
+ "sequence_number": 3,
+ "response": {
+ "id": "resp_docs_stream",
+ "object": "response",
+ "created_at": 1784292159,
+ "status": "completed",
+ "model": "openai/gpt-5.4-mini",
+ "output": [
+ {
+ "id": "msg_docs_stream",
+ "content": [
+ {
+ "text": "A model card",
+ "type": "output_text",
+ "annotations": []
+ }
+ ],
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ]
+ }
+ }
+]
diff --git a/packages/baseai/src/dev/providers/perplexity/legacyStream.test.ts b/packages/baseai/src/dev/providers/perplexity/legacyStream.test.ts
new file mode 100644
index 00000000..c35b9c6c
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/legacyStream.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { OPEN_AI, PERPLEXITY } from '@/dev/data/models';
+import { handleStreamingMode } from '@/dev/utils/provider-handlers/response-handler-utils';
+import { PerplexityAIChatCompleteStreamChunkTransform } from './chatComplete';
+
+async function readText(stream: ReadableStream): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let text = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) return text + decoder.decode();
+ text += decoder.decode(value, { stream: true });
+ }
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('existing provider streaming regressions', () => {
+ it('preserves the legacy Perplexity Chat Completions chunk envelope', async () => {
+ vi.spyOn(Date, 'now').mockReturnValue(1787928000000);
+ const upstreamChunk = {
+ id: 'legacy-123',
+ object: 'chat.completion.chunk',
+ created: 1787927999,
+ model: 'llama-3.1-sonar-small-128k-chat',
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ total_tokens: 0
+ },
+ choices: [
+ {
+ message: { role: 'assistant', content: '' },
+ delta: { role: 'assistant', content: 'Hello' },
+ index: 0,
+ finish_reason: null
+ }
+ ]
+ };
+ const upstream = `data: ${JSON.stringify(upstreamChunk)}\r\n\r\n`;
+ const stream = await handleStreamingMode(
+ new Response(upstream),
+ PERPLEXITY,
+ PerplexityAIChatCompleteStreamChunkTransform,
+ 'https://api.perplexity.ai/chat/completions'
+ );
+
+ expect(await readText(stream)).toBe(
+ `data: ${JSON.stringify({
+ id: 'legacy-123',
+ object: 'chat.completion.chunk',
+ created: 1787928000,
+ model: 'llama-3.1-sonar-small-128k-chat',
+ provider: 'Perplexity',
+ choices: [
+ {
+ delta: { role: 'assistant', content: 'Hello' },
+ index: 0,
+ finish_reason: null
+ }
+ ]
+ })}\n\n`
+ );
+ });
+
+ it('passes another existing provider stream through byte for byte', async () => {
+ const upstream =
+ 'data: {"id":"openai-1","delta":"one"}\n\n' +
+ 'data: {"id":"openai-1","delta":"two"}\n\n';
+ const stream = await handleStreamingMode(
+ new Response(upstream),
+ OPEN_AI,
+ undefined,
+ 'https://api.openai.com/v1/chat/completions'
+ );
+
+ expect(await readText(stream)).toBe(upstream);
+ });
+});
diff --git a/packages/baseai/src/dev/providers/perplexity/perplexity.test.ts b/packages/baseai/src/dev/providers/perplexity/perplexity.test.ts
new file mode 100644
index 00000000..bb1d274b
--- /dev/null
+++ b/packages/baseai/src/dev/providers/perplexity/perplexity.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import type { Pipe } from 'types/pipe';
+import { validateRequestBody } from '@/dev/routes/v1/pipes/run';
+import { toOldPipeFormat } from '@/utils/to-old-pipe-format';
+import {
+ PERPLEXITY_AGENT_PRESETS,
+ buildPerplexityAgentRequest,
+ getPerplexityTransport
+} from './agentResponse';
+
+const legacyModels = [
+ 'perplexity:llama-3.1-sonar-huge-128k-online',
+ 'perplexity:llama-3.1-sonar-large-128k-online',
+ 'perplexity:llama-3.1-sonar-small-128k-online',
+ 'perplexity:llama-3.1-sonar-large-128k-chat',
+ 'perplexity:llama-3.1-sonar-small-128k-chat'
+] as const;
+
+function scaffoldPipe(model: Pipe['model']): Pipe {
+ return {
+ name: 'perplexity-agent',
+ description: 'Perplexity Agent API example',
+ status: 'private',
+ model,
+ stream: true,
+ json: false,
+ store: true,
+ moderate: true,
+ top_p: 1,
+ max_tokens: 1000,
+ temperature: 0.7,
+ presence_penalty: 1,
+ frequency_penalty: 1,
+ stop: [],
+ tool_choice: 'auto',
+ parallel_tool_calls: true,
+ messages: [{ role: 'system', content: 'Be concise.' }],
+ variables: [],
+ memory: [],
+ tools: []
+ };
+}
+
+describe('Perplexity transport dispatch', () => {
+ it.each(legacyModels)('keeps %s on Chat Completions', model => {
+ expect(getPerplexityTransport(model)).toBe('chatComplete');
+ });
+
+ it.each(PERPLEXITY_AGENT_PRESETS)(
+ 'routes perplexity:%s to the matching Agent preset',
+ preset => {
+ const model = `perplexity:${preset}` as Pipe['model'];
+ expect(getPerplexityTransport(model)).toBe('agentResponse');
+ expect(
+ buildPerplexityAgentRequest({
+ pipe: scaffoldPipe(model),
+ messages: [{ role: 'user', content: 'Hello' }],
+ stream: false
+ }).preset
+ ).toBe(preset);
+ }
+ );
+});
+
+describe('Perplexity model string compatibility', () => {
+ it.each(PERPLEXITY_AGENT_PRESETS)(
+ 'preserves perplexity:%s through Pipe parsing and toOldPipeFormat',
+ preset => {
+ const model = `perplexity:${preset}` as Pipe['model'];
+ const pipe = scaffoldPipe(model);
+ const parsed = validateRequestBody({
+ pipe,
+ stream: false,
+ messages: [],
+ llmApiKey: 'test-key'
+ });
+ expect(parsed.pipe.model).toBe(model);
+
+ const oldPipe = toOldPipeFormat(pipe);
+ expect(oldPipe.config.model).toMatchObject({
+ name: preset,
+ provider: 'Perplexity'
+ });
+ expect(
+ `${oldPipe.config.model.provider.toLowerCase()}:${oldPipe.config.model.name}`
+ ).toBe(model);
+ }
+ );
+});
diff --git a/packages/baseai/src/dev/routes/v1/pipes/run.perplexity.test.ts b/packages/baseai/src/dev/routes/v1/pipes/run.perplexity.test.ts
new file mode 100644
index 00000000..940fedbf
--- /dev/null
+++ b/packages/baseai/src/dev/routes/v1/pipes/run.perplexity.test.ts
@@ -0,0 +1,276 @@
+import { handleError } from '@/dev/hono/errors';
+import documentedAgentResponse from '@/dev/providers/perplexity/fixtures/documented-agent-response.json';
+import documentedAgentStream from '@/dev/providers/perplexity/fixtures/documented-agent-stream.json';
+import { Hono, type Context } from 'hono';
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ type Mock,
+ vi
+} from 'vitest';
+import type { Pipe } from 'types/pipe';
+import { registerV1PipesRun } from './run';
+
+vi.mock('@/utils/logger-utils', () => ({ logger: vi.fn() }));
+
+const AGENT_ALIASES = ['fast', 'low', 'medium', 'high'] as const;
+const LEGACY_MODELS = [
+ 'llama-3.1-sonar-huge-128k-online',
+ 'llama-3.1-sonar-large-128k-online',
+ 'llama-3.1-sonar-small-128k-online',
+ 'llama-3.1-sonar-large-128k-chat',
+ 'llama-3.1-sonar-small-128k-chat'
+] as const;
+
+function scaffoldPipe(model: string, overrides: Partial = {}): Pipe {
+ return {
+ name: 'perplexity-agent',
+ description: 'Perplexity Agent route integration',
+ status: 'private',
+ model: model as Pipe['model'],
+ stream: true,
+ json: false,
+ store: true,
+ moderate: true,
+ top_p: 1,
+ max_tokens: 1000,
+ temperature: 0.7,
+ presence_penalty: 1,
+ frequency_penalty: 1,
+ stop: [],
+ tool_choice: 'auto',
+ parallel_tool_calls: true,
+ messages: [
+ {
+ role: 'system',
+ content: 'Answer concisely and cite factual claims.'
+ }
+ ],
+ variables: [],
+ memory: [],
+ tools: [],
+ ...overrides
+ };
+}
+
+function requestBody(pipe: Pipe, stream = false) {
+ return {
+ pipe,
+ stream,
+ messages: [{ role: 'user', content: 'What is a model card?' }],
+ llmApiKey: 'test-perplexity-key'
+ };
+}
+
+function encodeSSE(events: object[]): string {
+ return `${events
+ .map(event => `data: ${JSON.stringify(event)}\n\n`)
+ .join('')}data: [DONE]\n\n`;
+}
+
+function legacyResponse(model: string) {
+ return {
+ id: 'legacy-response',
+ object: 'chat.completion',
+ created: 1784292159,
+ model,
+ choices: [
+ {
+ index: 0,
+ message: { role: 'assistant', content: 'Legacy response' },
+ finish_reason: 'stop'
+ }
+ ],
+ usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }
+ };
+}
+
+describe('POST /v1/pipes/run Perplexity dispatch', () => {
+ let app: Hono;
+ let fetchMock: Mock, ReturnType>;
+
+ beforeEach(() => {
+ app = new Hono();
+ app.onError((error, context) =>
+ handleError(error, context as unknown as Context)
+ );
+ registerV1PipesRun(app);
+ fetchMock = vi.fn(async (input, init) => {
+ const url = String(input);
+ const body = JSON.parse(String(init?.body || '{}')) as {
+ model?: string;
+ stream?: boolean;
+ };
+ if (url.endsWith('/v1/agent')) {
+ if (body.stream) {
+ return new Response(encodeSSE(documentedAgentStream), {
+ status: 200,
+ headers: { 'content-type': 'text/event-stream' }
+ });
+ }
+ return new Response(JSON.stringify(documentedAgentResponse), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ });
+ }
+ return new Response(
+ JSON.stringify(legacyResponse(body.model || '')),
+ {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ }
+ );
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ async function runRoute(body: object): Promise {
+ return app.request('http://localhost/v1/pipes/run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ }
+
+ it.each(AGENT_ALIASES)(
+ 'routes perplexity:%s through every runtime layer to /v1/agent',
+ async preset => {
+ const response = await runRoute(
+ requestBody(scaffoldPipe(`perplexity:${preset}`))
+ );
+ expect(response.status).toBe(200);
+ const result = await response.json();
+ expect(result).toMatchObject({
+ completion:
+ 'A model card documents intended use and limitations.[web:4]',
+ model: 'openai/gpt-5.4-mini'
+ });
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0] as [
+ string,
+ RequestInit
+ ];
+ expect(url).toBe('https://api.perplexity.ai/v1/agent');
+ expect(init.method).toBe('POST');
+ expect(JSON.parse(String(init.body))).toEqual({
+ preset,
+ input: [
+ {
+ type: 'message',
+ role: 'system',
+ content: 'Answer concisely and cite factual claims.'
+ },
+ {
+ type: 'message',
+ role: 'user',
+ content: 'What is a model card?'
+ }
+ ],
+ max_output_tokens: 1000,
+ stream: false,
+ temperature: 0.7,
+ top_p: 1
+ });
+ }
+ );
+
+ it.each(LEGACY_MODELS)(
+ 'keeps perplexity:%s on Chat Completions without rewriting it',
+ async model => {
+ const response = await runRoute(
+ requestBody(scaffoldPipe(`perplexity:${model}`))
+ );
+ expect(response.status).toBe(200);
+ const [url, init] = fetchMock.mock.calls[0] as [
+ string,
+ RequestInit
+ ];
+ expect(url).toBe('https://api.perplexity.ai/chat/completions');
+ expect(JSON.parse(String(init.body)).model).toBe(model);
+ }
+ );
+
+ it('rejects request tools before provider fetch', async () => {
+ const response = await runRoute({
+ ...requestBody(scaffoldPipe('perplexity:fast')),
+ tools: [
+ {
+ type: 'function',
+ function: { name: 'lookup', parameters: { type: 'object' } }
+ }
+ ]
+ });
+ expect(response.status).toBe(400);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('streams one Agent response through the route', async () => {
+ const response = await runRoute(
+ requestBody(scaffoldPipe('perplexity:fast'), true)
+ );
+ expect(response.status).toBe(200);
+ const text = await response.text();
+ expect(text).toContain('A model card');
+ expect(text.match(/data: \[DONE\]/g)).toHaveLength(1);
+ });
+
+ it('surfaces a provider stream failure after response headers', async () => {
+ fetchMock.mockResolvedValueOnce(
+ new Response(
+ encodeSSE([
+ documentedAgentStream[0],
+ {
+ type: 'response.failed',
+ sequence_number: 1,
+ error: { message: 'stream failed' }
+ }
+ ]),
+ {
+ status: 200,
+ headers: { 'content-type': 'text/event-stream' }
+ }
+ )
+ );
+ const response = await runRoute(
+ requestBody(scaffoldPipe('perplexity:fast'), true)
+ );
+ expect(response.status).toBe(200);
+ await expect(response.text()).rejects.toThrow('stream failed');
+ });
+
+ it.each([
+ [401, 'UNAUTHORIZED'],
+ [403, 'FORBIDDEN'],
+ [404, 'NOT_FOUND'],
+ [429, 'RATE_LIMITED'],
+ [500, 'INTERNAL_SERVER_ERROR']
+ ] as const)(
+ 'preserves provider HTTP %i as %s at the route',
+ async (status, code) => {
+ fetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ error: { message: 'Provider error' } }),
+ {
+ status
+ }
+ )
+ );
+ const response = await runRoute(
+ requestBody(scaffoldPipe('perplexity:fast'))
+ );
+ expect(response.status).toBe(status);
+ await expect(response.json()).resolves.toMatchObject({
+ success: false,
+ error: { status, code }
+ });
+ }
+ );
+});
diff --git a/packages/baseai/src/dev/routes/v1/pipes/run.ts b/packages/baseai/src/dev/routes/v1/pipes/run.ts
index c58d0db2..aaa8d97f 100644
--- a/packages/baseai/src/dev/routes/v1/pipes/run.ts
+++ b/packages/baseai/src/dev/routes/v1/pipes/run.ts
@@ -51,7 +51,7 @@ const RequestBodySchema = z.object({
type RequestBody = z.infer;
// Helper functions
-const validateRequestBody = (body: unknown): RequestBody => {
+export const validateRequestBody = (body: unknown): RequestBody => {
const result = RequestBodySchema.safeParse(body);
if (!result.success) {
throw new ApiErrorZod({
diff --git a/packages/baseai/types/model.ts b/packages/baseai/types/model.ts
index 4ec94b0f..7bc15ab5 100644
--- a/packages/baseai/types/model.ts
+++ b/packages/baseai/types/model.ts
@@ -67,6 +67,10 @@ export type FireworksAIModels =
| 'fireworks:llama-v3p3-70b-instruct';
export type PerplexityModels =
+ | 'perplexity:fast'
+ | 'perplexity:low'
+ | 'perplexity:medium'
+ | 'perplexity:high'
| 'perplexity:llama-3.1-sonar-huge-128k-online'
| 'perplexity:llama-3.1-sonar-large-128k-online'
| 'perplexity:llama-3.1-sonar-small-128k-online'
diff --git a/packages/baseai/types/providers.ts b/packages/baseai/types/providers.ts
index 7e7cfff3..552ee868 100644
--- a/packages/baseai/types/providers.ts
+++ b/packages/baseai/types/providers.ts
@@ -98,6 +98,8 @@ export interface ProviderAPIConfig {
'stream-complete'?: string;
/** The endpoint for the 'chatComplete' function. */
chatComplete?: string;
+ /** The endpoint for the Perplexity Agent API operation. */
+ agentResponse?: string;
/** The endpoint for the 'stream-chatComplete' function. */
'stream-chatComplete'?: string;
/** The endpoint for the 'embed' function. */
@@ -121,6 +123,7 @@ export interface ProviderAPIConfig {
export type endpointStrings =
| 'complete'
| 'chatComplete'
+ | 'agentResponse'
| 'embed'
| 'rerank'
| 'moderate'
diff --git a/packages/baseai/vitest.edge.config.js b/packages/baseai/vitest.edge.config.js
new file mode 100644
index 00000000..0fdb6c90
--- /dev/null
+++ b/packages/baseai/vitest.edge.config.js
@@ -0,0 +1,20 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ types: fileURLToPath(new URL('./types', import.meta.url))
+ }
+ },
+ test: {
+ environment: 'edge-runtime',
+ globals: true,
+ include: ['**/*.test.ts'],
+ exclude: ['**/*.node.test.ts', '**/node_modules/**'],
+ typecheck: {
+ enabled: true
+ }
+ }
+});
diff --git a/packages/baseai/vitest.node.config.js b/packages/baseai/vitest.node.config.js
new file mode 100644
index 00000000..94f5e73a
--- /dev/null
+++ b/packages/baseai/vitest.node.config.js
@@ -0,0 +1,20 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ types: fileURLToPath(new URL('./types', import.meta.url))
+ }
+ },
+ test: {
+ environment: 'node',
+ globals: true,
+ include: ['**/*.test.ts'],
+ exclude: ['**/*.edge.test.ts', '**/node_modules/**'],
+ typecheck: {
+ enabled: true
+ }
+ }
+});
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 7d6cc9b1..7634f12c 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,14 @@
# `baseai` SDK
+## Unreleased
+
+### Minor Changes
+
+- Add the `perplexity:fast`, `perplexity:low`, `perplexity:medium`, and
+ `perplexity:high` model identifiers for local Perplexity Agent API Pipes.
+- Return tool-free streams without pre-reading them, expose Perplexity
+ citation metadata, and allow provider responses to omit unavailable usage.
+
## 0.9.43
### Patch Changes
diff --git a/packages/core/src/data/models.ts b/packages/core/src/data/models.ts
index be39bcd2..641b04d7 100644
--- a/packages/core/src/data/models.ts
+++ b/packages/core/src/data/models.ts
@@ -16,9 +16,9 @@ export const X_AI: string = 'xAI';
interface Model {
id: string;
provider: string;
- promptCost: number;
- completionCost: number;
- requestCost?: number;
+ promptCost: number | null;
+ completionCost: number | null;
+ requestCost?: number | null;
}
interface ModelsByProviderInclCosts {
@@ -377,6 +377,34 @@ export const modelsByProvider: ModelsByProviderInclCosts = {
},
],
[PERPLEXITY]: [
+ {
+ id: 'fast',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null,
+ },
+ {
+ id: 'low',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null,
+ },
+ {
+ id: 'medium',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null,
+ },
+ {
+ id: 'high',
+ provider: PERPLEXITY,
+ promptCost: null,
+ completionCost: null,
+ requestCost: null,
+ },
{
id: 'llama-3.1-sonar-huge-128k-online',
provider: PERPLEXITY,
diff --git a/packages/core/src/pipes/pipes.streaming.test.ts b/packages/core/src/pipes/pipes.streaming.test.ts
new file mode 100644
index 00000000..d8849419
--- /dev/null
+++ b/packages/core/src/pipes/pipes.streaming.test.ts
@@ -0,0 +1,61 @@
+import {describe, expect, it, vi} from 'vitest';
+import type {Pipe as PipeConfig} from '../../types/pipes';
+import {Pipe, type RunResponseStream} from './pipes';
+
+function toolFreePipe(): PipeConfig {
+ return {
+ apiKey: 'test-api-key',
+ name: 'perplexity-agent',
+ description: 'Perplexity Agent streaming test',
+ status: 'private',
+ model: 'perplexity:fast',
+ stream: true,
+ json: false,
+ store: true,
+ moderate: true,
+ top_p: 1,
+ max_tokens: 1000,
+ temperature: 0.7,
+ presence_penalty: 1,
+ frequency_penalty: 1,
+ stop: [],
+ tool_choice: 'auto',
+ parallel_tool_calls: true,
+ messages: [],
+ variables: [],
+ memory: [],
+ tools: [],
+ };
+}
+
+describe('Pipe tool-free streaming', () => {
+ it('returns the stream before the upstream source finishes', async () => {
+ let terminalReached = false;
+ let finishSource!: () => void;
+ const source = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode('first chunk'));
+ finishSource = () => {
+ terminalReached = true;
+ controller.close();
+ };
+ },
+ });
+ const response: RunResponseStream = {stream: source, threadId: null};
+ const pipe = new Pipe({...toolFreePipe(), prod: true});
+ const post = vi.fn().mockResolvedValue(response);
+ (pipe as unknown as {request: {post: typeof post}}).request = {post};
+
+ const delayedTerminal = setTimeout(() => finishSource(), 100);
+ const result = await pipe.run({messages: [], stream: true});
+
+ expect(result).toBe(response);
+ expect(terminalReached).toBe(false);
+ const reader = result.stream.getReader();
+ const first = await reader.read();
+ expect(new TextDecoder().decode(first.value)).toBe('first chunk');
+ reader.releaseLock();
+ clearTimeout(delayedTerminal);
+ finishSource();
+ });
+});
diff --git a/packages/core/src/pipes/pipes.ts b/packages/core/src/pipes/pipes.ts
index 410d74d4..97fbe43d 100644
--- a/packages/core/src/pipes/pipes.ts
+++ b/packages/core/src/pipes/pipes.ts
@@ -49,7 +49,7 @@ export interface RunResponse {
created: number;
model: string;
choices: ChoiceGenerate[];
- usage: Usage;
+ usage?: Usage;
system_fingerprint: string | null;
rawResponse?: {
headers: Record;
@@ -312,6 +312,9 @@ export class Pipe {
if (Object.entries(response).length === 0) {
return {} as RunResponse | RunResponseStream;
}
+ if (stream && !this.hasTools) {
+ return response as RunResponseStream;
+ }
if (!runTools) {
if (!stream) {
diff --git a/packages/core/types/model.ts b/packages/core/types/model.ts
index 960cdaad..5d3a609f 100644
--- a/packages/core/types/model.ts
+++ b/packages/core/types/model.ts
@@ -67,6 +67,10 @@ export type FireworksAIModels =
| 'fireworks:llama-v3p3-70b-instruct';
export type PerplexityModels =
+ | 'perplexity:fast'
+ | 'perplexity:low'
+ | 'perplexity:medium'
+ | 'perplexity:high'
| 'perplexity:llama-3.1-sonar-huge-128k-online'
| 'perplexity:llama-3.1-sonar-large-128k-online'
| 'perplexity:llama-3.1-sonar-small-128k-online'
diff --git a/packages/core/types/pipes.ts b/packages/core/types/pipes.ts
index d4390e86..c8c39e99 100644
--- a/packages/core/types/pipes.ts
+++ b/packages/core/types/pipes.ts
@@ -25,9 +25,21 @@ export interface ToolCallResult {
function: Function;
}
+export interface CitationSource {
+ startIndex?: number;
+ endIndex?: number;
+ uri?: string;
+ license?: string;
+}
+
+export interface CitationMetadata {
+ citationSources?: CitationSource[];
+}
+
export interface Message {
role: MessageRole;
content: string | null;
+ citationMetadata?: CitationMetadata;
name?: string;
tool_call_id?: string;
tool_calls?: ToolCallResult[];
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c323a229..ba4f904d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -389,18 +389,24 @@ importers:
examples/nodejs:
dependencies:
'@baseai/core':
- specifier: ^0.9.43
- version: 0.9.43(react@19.2.4)(zod@3.23.8)
+ specifier: workspace:*
+ version: link:../../packages/core
dotenv:
specifier: ^16.4.5
version: 16.4.5
devDependencies:
+ '@types/node':
+ specifier: ^22.6.1
+ version: 22.7.4
baseai:
- specifier: ^0.9.44
- version: 0.9.44(@types/node@22.7.4)(typescript@5.6.2)
+ specifier: workspace:*
+ version: link:../../packages/baseai
tsx:
specifier: ^4.19.0
version: 4.19.1
+ typescript:
+ specifier: ^5.6.2
+ version: 5.6.2
examples/remix:
dependencies: