diff --git a/docs/adapters/perplexity.md b/docs/adapters/perplexity.md new file mode 100644 index 000000000..e6d08e048 --- /dev/null +++ b/docs/adapters/perplexity.md @@ -0,0 +1,115 @@ +--- +title: Perplexity +id: perplexity-adapter +order: 10 +description: "Use the Perplexity Search API and OpenAI-compatible chat completions with TanStack AI via @tanstack/ai-perplexity." +keywords: + - tanstack ai + - perplexity + - search api + - web search + - adapter +--- + +`@tanstack/ai-perplexity` integrates [Perplexity](https://www.perplexity.ai) with TanStack AI: + +- A **Search API tool** that grounds your agent on the live web (`POST https://api.perplexity.ai/search`). +- An **OpenAI-compatible chat client** that points the `openai` SDK at Perplexity's chat-completions endpoint, so existing OpenAI code can target Perplexity by swapping the base URL. + +## Installation + +```bash +npm install @tanstack/ai-perplexity +``` + +Set your API key (get one at ): + +```bash +export PERPLEXITY_API_KEY=... +# PPLX_API_KEY is also accepted +``` + +## Search tool + +Wrap the Search API as a TanStack AI tool and pass it to a chat agent so the model can fetch up-to-date web results: + +```ts +import { chat } from '@tanstack/ai' +import { perplexitySearchTool } from '@tanstack/ai-perplexity' + +const search = perplexitySearchTool({ + // optional: applied when the model omits max_results + defaultMaxResults: 5, +}) + +const stream = chat({ + // ... your text adapter ... + tools: [search], + messages: [ + { role: 'user', content: 'What were the top AI papers this week?' }, + ], +}) +``` + +The tool input schema accepts: + +| Field | Type | Notes | +| --------------------------- | ------------------------------------------------- | ------------------------------------------------------------------ | +| `query` | `string` (required) | The search query. | +| `max_results` | `integer` (1–20) | Defaults to API default (10), or `defaultMaxResults` if configured.| +| `search_domain_filter` | `string[]` | Allowlist (`"nytimes.com"`) **or** denylist (`"-pinterest.com"`) — never both. | +| `search_recency_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Recency window. | +| `search_after_date_filter` | `string` | `m/d/yyyy` — only results on/after this date. | +| `search_before_date_filter` | `string` | `m/d/yyyy` — only results on/before this date. | + +Each result is `{ title, url, snippet, date? }`. + +### Direct client + +If you want to call the Search API outside an agent loop: + +```ts +import { PerplexitySearchClient } from '@tanstack/ai-perplexity' + +const client = new PerplexitySearchClient() +const { results } = await client.search({ + query: 'mars sample return mission', + max_results: 5, + search_recency_filter: 'month', +}) +``` + +## Chat (OpenAI-compatible) + +Perplexity exposes `POST /v1/chat/completions` with the standard OpenAI Chat Completions shape. `createPerplexityChatClient` returns an `openai` SDK instance pointed at `https://api.perplexity.ai`: + +```ts +import { createPerplexityChatClient } from '@tanstack/ai-perplexity/chat' + +const client = createPerplexityChatClient() +const completion = await client.chat.completions.create({ + model: 'sonar', + messages: [ + { role: 'user', content: 'What is the latest on the Mars rover?' }, + ], +}) +``` + +## Configuration + +```ts +import { PerplexitySearchClient } from '@tanstack/ai-perplexity' + +const client = new PerplexitySearchClient({ + apiKey: process.env.PERPLEXITY_API_KEY, // explicit key (optional) + baseURL: 'https://api.perplexity.ai', // override (optional) + fetch: globalThis.fetch, // custom fetch (optional) +}) +``` + +## References + +- Search quickstart: +- Search API reference: +- Domain filters: +- Date / recency filters: diff --git a/docs/config.json b/docs/config.json index f24a5fa0a..5e1b8d3eb 100644 --- a/docs/config.json +++ b/docs/config.json @@ -285,6 +285,10 @@ { "label": "OpenRouter Adapter", "to": "adapters/openrouter" + }, + { + "label": "Perplexity", + "to": "adapters/perplexity" } ] }, diff --git a/packages/typescript/ai-perplexity/README.md b/packages/typescript/ai-perplexity/README.md new file mode 100644 index 000000000..4561084c8 --- /dev/null +++ b/packages/typescript/ai-perplexity/README.md @@ -0,0 +1,91 @@ +# @tanstack/ai-perplexity + +[Perplexity](https://www.perplexity.ai) integration for [TanStack AI](https://tanstack.com/ai): + +- **Search API tool** — call `POST https://api.perplexity.ai/search` from an LLM agent loop and get back ranked web results (`title`, `url`, `snippet`, `date?`) suitable for grounding/citation. +- **OpenAI-compatible chat client** — a thin factory that points the `openai` SDK at Perplexity's chat-completions endpoint so you can reuse existing OpenAI code paths. + +## Install + +```bash +pnpm add @tanstack/ai-perplexity +``` + +Set your API key (get one at ): + +```bash +export PERPLEXITY_API_KEY=... +# PPLX_API_KEY is also accepted +``` + +## Search tool + +Wrap the Search API as a TanStack AI tool and pass it to a chat agent: + +```ts +import { perplexitySearchTool } from '@tanstack/ai-perplexity' + +const search = perplexitySearchTool({ + // optional defaults + defaultMaxResults: 5, +}) + +// Use directly with chat() +chat({ + tools: [search], + // ... +}) +``` + +The tool input schema accepts: + +| field | type | notes | +| --------------------------- | --------------------------------------------------- | ------------------------------------------------------------------ | +| `query` | `string` (required) | The search query. | +| `max_results` | `integer` (1–20) | Defaults to API default (10), or `defaultMaxResults` if configured.| +| `search_domain_filter` | `string[]` | Allowlist (`"nytimes.com"`) **or** denylist (`"-pinterest.com"`) — never both. | +| `search_recency_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Recency window. | +| `search_after_date_filter` | `string` | `m/d/yyyy` — only results on/after this date. | +| `search_before_date_filter` | `string` | `m/d/yyyy` — only results on/before this date. | + +Output: `{ results: Array<{ title, url, snippet, date? }> }`. + +### Direct client usage + +If you don't need the tool wrapping, call the Search API directly: + +```ts +import { PerplexitySearchClient } from '@tanstack/ai-perplexity' + +const client = new PerplexitySearchClient() +const { results } = await client.search({ + query: 'mars sample return mission', + max_results: 5, + search_recency_filter: 'month', +}) +``` + +## Chat (OpenAI-compatible) + +Perplexity's chat completions endpoint is OpenAI-compatible, so you can target it by swapping the `baseURL`: + +```ts +import { createPerplexityChatClient } from '@tanstack/ai-perplexity/chat' + +const client = createPerplexityChatClient() +const completion = await client.chat.completions.create({ + model: 'sonar', + messages: [ + { role: 'user', content: 'What is the latest on the Mars rover?' }, + ], +}) +``` + +Env vars: `PERPLEXITY_API_KEY` (preferred) or `PPLX_API_KEY`. + +## Docs + +- Search quickstart: +- Search API reference: +- Domain filters: +- Date / recency filters: diff --git a/packages/typescript/ai-perplexity/package.json b/packages/typescript/ai-perplexity/package.json new file mode 100644 index 000000000..c77179058 --- /dev/null +++ b/packages/typescript/ai-perplexity/package.json @@ -0,0 +1,61 @@ +{ + "name": "@tanstack/ai-perplexity", + "version": "0.1.0", + "description": "Perplexity adapter for TanStack AI — Search API and OpenAI-compatible chat", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-perplexity" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./search": { + "types": "./dist/esm/search/index.d.ts", + "import": "./dist/esm/search/index.js" + }, + "./chat": { + "types": "./dist/esm/chat/index.d.ts", + "import": "./dist/esm/chat/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "perplexity", + "search", + "tanstack", + "adapter" + ], + "dependencies": { + "openai": "^6.9.1" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^" + } +} diff --git a/packages/typescript/ai-perplexity/src/chat/client.ts b/packages/typescript/ai-perplexity/src/chat/client.ts new file mode 100644 index 000000000..9cf54c203 --- /dev/null +++ b/packages/typescript/ai-perplexity/src/chat/client.ts @@ -0,0 +1,42 @@ +import OpenAI from 'openai' +import { getPerplexityApiKeyFromEnv } from '../utils/api-key' +import type { ClientOptions } from 'openai' + +export interface PerplexityChatClientConfig extends ClientOptions { + /** Perplexity API key. Falls back to `PERPLEXITY_API_KEY` / `PPLX_API_KEY` env vars. */ + apiKey?: string + /** Override the API base URL (defaults to https://api.perplexity.ai). */ + baseURL?: string +} + +const DEFAULT_BASE_URL = 'https://api.perplexity.ai' + +/** + * Create an OpenAI SDK client pointed at Perplexity's OpenAI-compatible + * chat-completions endpoint. + * + * Perplexity exposes `POST /v1/chat/completions` with the standard OpenAI + * Chat Completions request/response shape, so any code that consumes the + * `openai` SDK can target Perplexity by swapping the `baseURL`. + * + * @example + * ```ts + * import { createPerplexityChatClient } from '@tanstack/ai-perplexity/chat' + * + * const client = createPerplexityChatClient() + * const completion = await client.chat.completions.create({ + * model: 'sonar', + * messages: [{ role: 'user', content: 'What is the latest on the Mars rover?' }], + * }) + * ``` + */ +export function createPerplexityChatClient( + config: PerplexityChatClientConfig = {}, +): OpenAI { + const { apiKey, baseURL, ...rest } = config + return new OpenAI({ + ...rest, + apiKey: apiKey ?? getPerplexityApiKeyFromEnv(), + baseURL: baseURL ?? DEFAULT_BASE_URL, + }) +} diff --git a/packages/typescript/ai-perplexity/src/chat/index.ts b/packages/typescript/ai-perplexity/src/chat/index.ts new file mode 100644 index 000000000..369b43ac4 --- /dev/null +++ b/packages/typescript/ai-perplexity/src/chat/index.ts @@ -0,0 +1,4 @@ +export { + createPerplexityChatClient, + type PerplexityChatClientConfig, +} from './client' diff --git a/packages/typescript/ai-perplexity/src/index.ts b/packages/typescript/ai-perplexity/src/index.ts new file mode 100644 index 000000000..897e3e464 --- /dev/null +++ b/packages/typescript/ai-perplexity/src/index.ts @@ -0,0 +1,18 @@ +// Search API +export { + PerplexitySearchClient, + perplexitySearchTool, + type PerplexitySearchClientConfig, + type PerplexitySearchRequest, + type PerplexitySearchResponse, + type PerplexitySearchResult, +} from './search' + +// OpenAI-compatible chat client (Perplexity chat completions endpoint) +export { + createPerplexityChatClient, + type PerplexityChatClientConfig, +} from './chat' + +// Utilities +export { getPerplexityApiKeyFromEnv } from './utils/api-key' diff --git a/packages/typescript/ai-perplexity/src/search/client.ts b/packages/typescript/ai-perplexity/src/search/client.ts new file mode 100644 index 000000000..30eed27fd --- /dev/null +++ b/packages/typescript/ai-perplexity/src/search/client.ts @@ -0,0 +1,144 @@ +import { getPerplexityApiKeyFromEnv } from '../utils/api-key' + +export interface PerplexitySearchClientConfig { + /** Perplexity API key. Falls back to `PERPLEXITY_API_KEY` / `PPLX_API_KEY` env vars. */ + apiKey?: string + /** Override the API base URL (defaults to https://api.perplexity.ai). */ + baseURL?: string + /** Optional `fetch` implementation; defaults to globalThis.fetch. */ + fetch?: typeof fetch +} + +export interface PerplexitySearchRequest { + /** The search query. */ + query: string + /** Maximum number of results to return (1–20). Defaults to the API default (10). */ + max_results?: number + /** Maximum tokens of content to return per page. */ + max_tokens_per_page?: number + /** + * Restrict (or exclude) results by domain. + * + * Use bare hostnames to allowlist (`["nytimes.com"]`) or `-` prefixed entries + * to denylist (`["-pinterest.com"]`). Allow and deny entries must NOT be + * mixed in the same request. + */ + search_domain_filter?: Array + /** Restrict results by recency: `hour | day | week | month | year`. */ + search_recency_filter?: 'hour' | 'day' | 'week' | 'month' | 'year' + /** Only include results published on or after this date (m/d/yyyy). */ + search_after_date_filter?: string + /** Only include results published on or before this date (m/d/yyyy). */ + search_before_date_filter?: string +} + +export interface PerplexitySearchResult { + title: string + url: string + snippet: string + date?: string +} + +export interface PerplexitySearchResponse { + id?: string + results: Array +} + +const DEFAULT_BASE_URL = 'https://api.perplexity.ai' + +/** + * Low-level HTTP client for the Perplexity Search API. + * + * Calls `POST {baseURL}/search` with bearer auth. + */ +export class PerplexitySearchClient { + private readonly apiKey: string + private readonly baseURL: string + private readonly fetchImpl: typeof fetch + + constructor(config: PerplexitySearchClientConfig = {}) { + this.apiKey = config.apiKey ?? getPerplexityApiKeyFromEnv() + this.baseURL = (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, '') + this.fetchImpl = config.fetch ?? globalThis.fetch + } + + async search( + request: PerplexitySearchRequest, + init: { signal?: AbortSignal } = {}, + ): Promise { + if (!request.query || typeof request.query !== 'string') { + throw new Error('PerplexitySearchClient.search requires a non-empty `query`.') + } + validateDomainFilter(request.search_domain_filter) + + const body: Record = { query: request.query } + if (request.max_results !== undefined) body.max_results = request.max_results + if (request.max_tokens_per_page !== undefined) + body.max_tokens_per_page = request.max_tokens_per_page + if (request.search_domain_filter) + body.search_domain_filter = request.search_domain_filter + if (request.search_recency_filter) + body.search_recency_filter = request.search_recency_filter + if (request.search_after_date_filter) + body.search_after_date_filter = request.search_after_date_filter + if (request.search_before_date_filter) + body.search_before_date_filter = request.search_before_date_filter + + const response = await this.fetchImpl(`${this.baseURL}/search`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal: init.signal, + }) + + if (!response.ok) { + const text = await safeReadText(response) + throw new Error( + `Perplexity Search API request failed: ${response.status} ${response.statusText}${ + text ? ` — ${text}` : '' + }`, + ) + } + + const data = (await response.json()) as PerplexitySearchResponse + return { + id: data.id, + results: Array.isArray(data.results) + ? data.results.map((r) => ({ + title: r.title, + url: r.url, + snippet: r.snippet, + ...(r.date ? { date: r.date } : {}), + })) + : [], + } + } +} + +function validateDomainFilter(filter: Array | undefined): void { + if (!filter || filter.length === 0) return + let hasAllow = false + let hasDeny = false + for (const entry of filter) { + if (typeof entry !== 'string' || entry.length === 0) continue + if (entry.startsWith('-')) hasDeny = true + else hasAllow = true + } + if (hasAllow && hasDeny) { + throw new Error( + 'search_domain_filter cannot mix allowlist and denylist entries. Use only `-domain.com` for negation, or only bare domains for allowlist.', + ) + } +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text() + } catch { + return '' + } +} diff --git a/packages/typescript/ai-perplexity/src/search/index.ts b/packages/typescript/ai-perplexity/src/search/index.ts new file mode 100644 index 000000000..6999d554d --- /dev/null +++ b/packages/typescript/ai-perplexity/src/search/index.ts @@ -0,0 +1,8 @@ +export { + PerplexitySearchClient, + type PerplexitySearchClientConfig, + type PerplexitySearchRequest, + type PerplexitySearchResponse, + type PerplexitySearchResult, +} from './client' +export { perplexitySearchTool } from './tool' diff --git a/packages/typescript/ai-perplexity/src/search/tool.ts b/packages/typescript/ai-perplexity/src/search/tool.ts new file mode 100644 index 000000000..925676770 --- /dev/null +++ b/packages/typescript/ai-perplexity/src/search/tool.ts @@ -0,0 +1,137 @@ +import { toolDefinition } from '@tanstack/ai' +import { PerplexitySearchClient } from './client' +import type { + PerplexitySearchClientConfig, + PerplexitySearchResult, +} from './client' + +/** + * Build a TanStack AI tool that performs real-time web search via Perplexity. + * + * The tool returns an array of `{title, url, snippet, date?}` results suitable + * for citation/grounding in an LLM agent loop. + * + * @example + * ```ts + * import { perplexitySearchTool } from '@tanstack/ai-perplexity' + * + * const search = perplexitySearchTool().server(async (args) => args) + * ``` + */ +export function perplexitySearchTool( + config: PerplexitySearchClientConfig & { + /** Override the tool name (defaults to `perplexity_search`). */ + name?: string + /** Override the tool description shown to the model. */ + description?: string + /** Default max_results applied when the model does not provide one. */ + defaultMaxResults?: number + } = {}, +) { + const { + name, + description, + defaultMaxResults, + ...clientConfig + } = config + + // Lazily construct the client so missing API keys don't blow up at import + // time (e.g. on bundlers that statically evaluate module top-level). + let client: PerplexitySearchClient | null = null + const getClient = () => { + if (!client) client = new PerplexitySearchClient(clientConfig) + return client + } + + return toolDefinition({ + name: name ?? 'perplexity_search', + description: + description ?? + 'Search the web for up-to-date information using the Perplexity Search API. Returns a ranked list of web results with titles, URLs, snippets, and publication dates.', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { + type: 'string', + description: 'The search query string.', + }, + max_results: { + type: 'integer', + minimum: 1, + maximum: 20, + description: + 'Maximum number of results to return. Defaults to the API default (10).', + }, + search_domain_filter: { + type: 'array', + items: { type: 'string' }, + description: + 'Restrict results by domain. Use bare hostnames to allowlist (e.g. ["nytimes.com"]) or "-domain.com" to denylist. Allow and deny entries must NOT be mixed.', + }, + search_recency_filter: { + type: 'string', + enum: ['hour', 'day', 'week', 'month', 'year'], + description: 'Only include results from the given recency window.', + }, + search_after_date_filter: { + type: 'string', + description: 'Only include results published on or after this date (m/d/yyyy).', + }, + search_before_date_filter: { + type: 'string', + description: 'Only include results published on or before this date (m/d/yyyy).', + }, + }, + }, + outputSchema: { + type: 'object', + additionalProperties: false, + required: ['results'], + properties: { + results: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'url', 'snippet'], + properties: { + title: { type: 'string' }, + url: { type: 'string' }, + snippet: { type: 'string' }, + date: { type: 'string' }, + }, + }, + }, + }, + }, + }).server(async (args) => { + const input = (args ?? {}) as { + query: string + max_results?: number + search_domain_filter?: Array + search_recency_filter?: 'hour' | 'day' | 'week' | 'month' | 'year' + search_after_date_filter?: string + search_before_date_filter?: string + } + + const response = await getClient().search({ + query: input.query, + max_results: input.max_results ?? defaultMaxResults, + search_domain_filter: input.search_domain_filter, + search_recency_filter: input.search_recency_filter, + search_after_date_filter: input.search_after_date_filter, + search_before_date_filter: input.search_before_date_filter, + }) + + const results: Array = response.results.map((r) => ({ + title: r.title, + url: r.url, + snippet: r.snippet, + ...(r.date ? { date: r.date } : {}), + })) + + return { results } + }) +} diff --git a/packages/typescript/ai-perplexity/src/utils/api-key.ts b/packages/typescript/ai-perplexity/src/utils/api-key.ts new file mode 100644 index 000000000..4c732bace --- /dev/null +++ b/packages/typescript/ai-perplexity/src/utils/api-key.ts @@ -0,0 +1,24 @@ +/** + * Resolve a Perplexity API key from environment variables. + * + * Honors `PERPLEXITY_API_KEY` first, then falls back to `PPLX_API_KEY`. + * Throws if neither is set. + */ +export function getPerplexityApiKeyFromEnv(): string { + const env = + typeof globalThis !== 'undefined' && (globalThis as any).window?.env + ? (globalThis as any).window.env + : typeof process !== 'undefined' + ? process.env + : undefined + + const key = env?.PERPLEXITY_API_KEY || env?.PPLX_API_KEY + + if (!key) { + throw new Error( + 'PERPLEXITY_API_KEY (or PPLX_API_KEY) is required. Set it in your environment or pass an explicit apiKey.', + ) + } + + return key +} diff --git a/packages/typescript/ai-perplexity/src/utils/index.ts b/packages/typescript/ai-perplexity/src/utils/index.ts new file mode 100644 index 000000000..12f15f8d7 --- /dev/null +++ b/packages/typescript/ai-perplexity/src/utils/index.ts @@ -0,0 +1 @@ +export { getPerplexityApiKeyFromEnv } from './api-key' diff --git a/packages/typescript/ai-perplexity/tests/chat-client.test.ts b/packages/typescript/ai-perplexity/tests/chat-client.test.ts new file mode 100644 index 000000000..ddd20f0c1 --- /dev/null +++ b/packages/typescript/ai-perplexity/tests/chat-client.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createPerplexityChatClient } from '../src/chat/client' + +describe('createPerplexityChatClient', () => { + const ORIGINAL_ENV = { ...process.env } + + beforeEach(() => { + process.env.PERPLEXITY_API_KEY = 'test-key' + delete process.env.PPLX_API_KEY + }) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + }) + + it('constructs an OpenAI-compatible client pointed at api.perplexity.ai by default', () => { + const client = createPerplexityChatClient() + expect(String(client.baseURL)).toContain('api.perplexity.ai') + expect(client.apiKey).toBe('test-key') + }) + + it('uses an explicit apiKey over the env var', () => { + const client = createPerplexityChatClient({ apiKey: 'explicit' }) + expect(client.apiKey).toBe('explicit') + }) + + it('falls back to PPLX_API_KEY when PERPLEXITY_API_KEY is not set', () => { + delete process.env.PERPLEXITY_API_KEY + process.env.PPLX_API_KEY = 'fallback' + const client = createPerplexityChatClient() + expect(client.apiKey).toBe('fallback') + }) + + it('respects a baseURL override', () => { + const client = createPerplexityChatClient({ + apiKey: 'k', + baseURL: 'https://example.com/v1', + }) + expect(String(client.baseURL)).toContain('example.com') + }) + + it('throws when no API key is available', () => { + delete process.env.PERPLEXITY_API_KEY + delete process.env.PPLX_API_KEY + expect(() => createPerplexityChatClient()).toThrow(/PERPLEXITY_API_KEY/) + }) +}) diff --git a/packages/typescript/ai-perplexity/tests/search-client.test.ts b/packages/typescript/ai-perplexity/tests/search-client.test.ts new file mode 100644 index 000000000..e7342ed64 --- /dev/null +++ b/packages/typescript/ai-perplexity/tests/search-client.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PerplexitySearchClient } from '../src/search/client' + +describe('PerplexitySearchClient', () => { + const ORIGINAL_ENV = { ...process.env } + + beforeEach(() => { + process.env.PERPLEXITY_API_KEY = 'test-key' + delete process.env.PPLX_API_KEY + }) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + vi.restoreAllMocks() + }) + + function makeFetchMock(payload: unknown, status = 200) { + return vi.fn(async (_url: string, _init: RequestInit) => { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + } + + it('POSTs to /search with bearer auth and JSON body', async () => { + const fetchMock = makeFetchMock({ + id: 'q1', + results: [ + { + title: 'Example', + url: 'https://example.com', + snippet: 'Hello world', + date: '2024-01-15', + }, + ], + }) + + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + const res = await client.search({ query: 'mars rover', max_results: 3 }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://api.perplexity.ai/search') + expect((init as RequestInit).method).toBe('POST') + + const headers = (init as RequestInit).headers as Record + expect(headers.Authorization).toBe('Bearer test-key') + expect(headers['Content-Type']).toBe('application/json') + + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + query: 'mars rover', + max_results: 3, + }) + + expect(res.id).toBe('q1') + expect(res.results).toHaveLength(1) + expect(res.results[0]).toEqual({ + title: 'Example', + url: 'https://example.com', + snippet: 'Hello world', + date: '2024-01-15', + }) + }) + + it('forwards optional filters in the request body', async () => { + const fetchMock = makeFetchMock({ results: [] }) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + + await client.search({ + query: 'climate', + max_results: 5, + max_tokens_per_page: 512, + search_domain_filter: ['nytimes.com', 'reuters.com'], + search_recency_filter: 'month', + search_after_date_filter: '1/1/2025', + search_before_date_filter: '12/31/2025', + }) + + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body).toEqual({ + query: 'climate', + max_results: 5, + max_tokens_per_page: 512, + search_domain_filter: ['nytimes.com', 'reuters.com'], + search_recency_filter: 'month', + search_after_date_filter: '1/1/2025', + search_before_date_filter: '12/31/2025', + }) + }) + + it('rejects mixing allow + deny entries in search_domain_filter', async () => { + const fetchMock = makeFetchMock({ results: [] }) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + + await expect( + client.search({ + query: 'x', + search_domain_filter: ['nytimes.com', '-pinterest.com'], + }), + ).rejects.toThrow(/cannot mix allowlist and denylist/i) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('throws when query is missing', async () => { + const fetchMock = makeFetchMock({ results: [] }) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + await expect( + client.search({ query: '' as unknown as string }), + ).rejects.toThrow(/non-empty `query`/i) + }) + + it('falls back to PPLX_API_KEY when PERPLEXITY_API_KEY is not set', async () => { + delete process.env.PERPLEXITY_API_KEY + process.env.PPLX_API_KEY = 'fallback-key' + const fetchMock = makeFetchMock({ results: [] }) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + await client.search({ query: 'q' }) + const headers = fetchMock.mock.calls[0]![1].headers as Record + expect(headers.Authorization).toBe('Bearer fallback-key') + }) + + it('throws if neither env var is set and no apiKey is passed', () => { + delete process.env.PERPLEXITY_API_KEY + delete process.env.PPLX_API_KEY + expect( + () => new PerplexitySearchClient({ fetch: vi.fn() as any }), + ).toThrow(/PERPLEXITY_API_KEY/) + }) + + it('surfaces non-2xx responses as errors', async () => { + const fetchMock = vi.fn( + async () => + new Response('rate limited', { + status: 429, + statusText: 'Too Many Requests', + }), + ) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + await expect(client.search({ query: 'x' })).rejects.toThrow( + /429.*Too Many Requests.*rate limited/, + ) + }) + + it('omits date when API does not return one', async () => { + const fetchMock = makeFetchMock({ + results: [{ title: 't', url: 'u', snippet: 's' }], + }) + const client = new PerplexitySearchClient({ fetch: fetchMock as any }) + const res = await client.search({ query: 'q' }) + expect(res.results[0]).toEqual({ title: 't', url: 'u', snippet: 's' }) + expect('date' in res.results[0]!).toBe(false) + }) + + it('respects a custom baseURL', async () => { + const fetchMock = makeFetchMock({ results: [] }) + const client = new PerplexitySearchClient({ + apiKey: 'k', + baseURL: 'https://example.com/api/', + fetch: fetchMock as any, + }) + await client.search({ query: 'q' }) + expect(fetchMock.mock.calls[0]![0]).toBe('https://example.com/api/search') + }) +}) diff --git a/packages/typescript/ai-perplexity/tests/search-tool.test.ts b/packages/typescript/ai-perplexity/tests/search-tool.test.ts new file mode 100644 index 000000000..a5beaffaf --- /dev/null +++ b/packages/typescript/ai-perplexity/tests/search-tool.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { perplexitySearchTool } from '../src/search/tool' + +describe('perplexitySearchTool', () => { + beforeEach(() => { + process.env.PERPLEXITY_API_KEY = 'test-key' + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes a sensible default name, description, and schema', () => { + const tool = perplexitySearchTool({ + apiKey: 'k', + fetch: vi.fn() as any, + }) + expect(tool.name).toBe('perplexity_search') + expect(tool.description).toMatch(/Perplexity Search API/i) + // Must not leak Sonar references in user-facing description + expect(tool.description.toLowerCase()).not.toContain('sonar') + expect(tool.inputSchema).toMatchObject({ + type: 'object', + required: ['query'], + }) + }) + + it('executes the server tool against the mocked fetch', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + results: [ + { + title: 'A', + url: 'https://a.test', + snippet: 'snip', + date: '2025-03-01', + }, + { title: 'B', url: 'https://b.test', snippet: 'snip2' }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const tool = perplexitySearchTool({ + apiKey: 'k', + fetch: fetchMock as any, + defaultMaxResults: 7, + }) + + expect(typeof tool.execute).toBe('function') + const out = await tool.execute!({ query: 'foo' } as any) + expect(out).toEqual({ + results: [ + { + title: 'A', + url: 'https://a.test', + snippet: 'snip', + date: '2025-03-01', + }, + { title: 'B', url: 'https://b.test', snippet: 'snip2' }, + ], + }) + + // Default max_results should be applied when caller omits it + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body.max_results).toBe(7) + expect(body.query).toBe('foo') + }) + + it('passes through filter args from the model', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + const tool = perplexitySearchTool({ + apiKey: 'k', + fetch: fetchMock as any, + }) + + await tool.execute!({ + query: 'q', + max_results: 2, + search_domain_filter: ['arxiv.org'], + search_recency_filter: 'week', + search_after_date_filter: '1/1/2026', + } as any) + + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) + expect(body).toEqual({ + query: 'q', + max_results: 2, + search_domain_filter: ['arxiv.org'], + search_recency_filter: 'week', + search_after_date_filter: '1/1/2026', + }) + }) + + it('honors custom name and description overrides', () => { + const tool = perplexitySearchTool({ + apiKey: 'k', + fetch: vi.fn() as any, + name: 'web_search', + description: 'Custom desc.', + }) + expect(tool.name).toBe('web_search') + expect(tool.description).toBe('Custom desc.') + }) +}) diff --git a/packages/typescript/ai-perplexity/tsconfig.json b/packages/typescript/ai-perplexity/tsconfig.json new file mode 100644 index 000000000..ea11c1096 --- /dev/null +++ b/packages/typescript/ai-perplexity/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} diff --git a/packages/typescript/ai-perplexity/vite.config.ts b/packages/typescript/ai-perplexity/vite.config.ts new file mode 100644 index 000000000..a72d0a5f0 --- /dev/null +++ b/packages/typescript/ai-perplexity/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts', './src/search/index.ts', './src/chat/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb41b0817..a837f9104 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1335,6 +1335,22 @@ importers: specifier: ^4.2.0 version: 4.3.6 + packages/typescript/ai-perplexity: + dependencies: + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.19.0)(zod@4.3.6) + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + vite: + specifier: ^7.2.7 + version: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typescript/ai-preact: dependencies: '@tanstack/ai': @@ -16450,7 +16466,7 @@ snapshots: cheerio: 1.1.2 exsolve: 1.0.8 pathe: 2.0.3 - srvx: 0.11.2 + srvx: 0.11.15 tinyglobby: 0.2.16 ufo: 1.6.3 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -16480,7 +16496,7 @@ snapshots: cheerio: 1.1.2 exsolve: 1.0.8 pathe: 2.0.3 - srvx: 0.11.2 + srvx: 0.11.15 tinyglobby: 0.2.16 ufo: 1.6.3 vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -16510,7 +16526,7 @@ snapshots: cheerio: 1.1.2 exsolve: 1.0.8 pathe: 2.0.3 - srvx: 0.11.2 + srvx: 0.11.15 tinyglobby: 0.2.16 ufo: 1.6.3 vite: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -17361,7 +17377,7 @@ snapshots: '@vue/shared': 3.5.25 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.6 + postcss: 8.5.9 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.25': @@ -23376,8 +23392,8 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.6 - rollup: 4.57.1 + postcss: 8.5.9 + rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 24.10.3 @@ -23424,12 +23440,12 @@ snapshots: vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 24.10.3 fsevents: 2.3.3 @@ -23441,12 +23457,12 @@ snapshots: vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.0.1 fsevents: 2.3.3 @@ -23807,7 +23823,7 @@ snapshots: '@poppinss/colors': 4.1.6 '@poppinss/dumper': 0.6.5 '@speed-highlight/core': 1.2.12 - cookie-es: 2.0.0 + cookie-es: 2.0.1 youch-core: 0.3.3 zimmerframe@1.1.4: {}